diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index e687f61..c622057 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -1516,6 +1516,13 @@ margin-bottom: 0.75rem; } +.setup-field-note { + font-size: 0.75rem; + color: var(--admin-text-muted); + margin-top: -0.5rem; + margin-bottom: 0.75rem; +} + .setup-link { color: var(--t-text-primary, #171717); text-decoration: underline; @@ -1700,6 +1707,283 @@ margin: 0.25rem 0 1rem; } +/* ── Guided setup flow ── */ + +.setup-guided { + max-width: 600px; + margin: 0 auto; +} + +/* Progress bar */ +.setup-progress { + margin-bottom: 2rem; +} + +.setup-progress-list { + display: flex; + justify-content: center; + gap: 0.5rem; + list-style: none; + padding: 0; + margin: 0; +} + +.setup-progress-item { + flex: 1; + max-width: 140px; +} + +.setup-progress-button { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.5rem; + border: none; + background: none; + cursor: pointer; + font: inherit; + color: var(--admin-text-muted); + transition: color 0.15s; +} + +.setup-progress-button:hover:not(:disabled) { + color: var(--t-text-primary, #171717); +} + +.setup-progress-button:disabled { + cursor: default; +} + +.setup-progress-indicator { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 50%; + border: 2px solid var(--t-border-default, #d4d4d4); + background: var(--t-surface-base, #fff); + font-size: 0.875rem; + font-weight: 600; + transition: all 0.15s; +} + +.setup-progress-done .setup-progress-indicator { + background: var(--t-status-success, #22c55e); + border-color: var(--t-status-success, #22c55e); + color: #fff; +} + +.setup-progress-current .setup-progress-indicator { + border-color: var(--t-accent, #6366f1); + color: var(--t-accent, #6366f1); +} + +.setup-progress-label { + font-size: 0.75rem; + font-weight: 500; +} + +.setup-progress-current .setup-progress-label { + color: var(--t-text-primary, #171717); + font-weight: 600; +} + +/* Intro screen */ +.setup-intro { + text-align: center; + padding: 1.5rem; + border: 1px solid var(--t-border-default, #d4d4d4); + border-radius: 0.75rem; + background: var(--t-surface-base, #fff); +} + +.setup-intro-content { + text-align: left; + max-width: 480px; + margin: 0 auto; +} + +.setup-intro-lead { + font-size: 1rem; + line-height: 1.6; + margin-bottom: 1.5rem; +} + +.setup-intro-list { + list-style: none; + padding: 0; + margin: 0 0 1.5rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.setup-intro-list li { + display: flex; + align-items: flex-start; + gap: 0.75rem; +} + +.setup-intro-icon { + width: 1.25rem; + height: 1.25rem; + color: var(--t-accent, #6366f1); + flex-shrink: 0; + margin-top: 0.125rem; +} + +.setup-intro-list strong { + display: block; + font-weight: 600; + margin-bottom: 0.125rem; +} + +.setup-intro-list span { + font-size: 0.875rem; + color: var(--admin-text-muted); +} + +.setup-intro-note { + font-size: 0.875rem; + color: var(--admin-text-muted); + padding: 0.75rem 1rem; + background: var(--t-surface-sunken, #f5f5f5); + border-radius: 0.5rem; + margin-bottom: 1rem; +} + +.setup-actions-center { + justify-content: center; +} + +/* Step container */ +.setup-step { + border: 1px solid var(--t-border-default, #d4d4d4); + border-radius: 0.75rem; + background: var(--t-surface-base, #fff); + overflow: hidden; +} + +.setup-step-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--t-border-default, #d4d4d4); + background: var(--t-surface-sunken, #f5f5f5); +} + +.setup-step-number { + display: flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: 50%; + background: var(--t-accent, #6366f1); + color: #fff; + font-size: 0.875rem; + font-weight: 600; +} + +.setup-step-number-done { + background: var(--t-status-success, #22c55e); +} + +.setup-step-title { + font-size: 1rem; + font-weight: 600; + margin: 0; +} + +.setup-step-body { + padding: 1.25rem; +} + +.setup-step-done { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + background: color-mix(in oklab, var(--t-status-success, #22c55e) 10%, transparent); + border-radius: 0.5rem; + margin-bottom: 1rem; +} + +.setup-step-done-icon { + width: 1.25rem; + height: 1.25rem; + color: var(--t-status-success, #22c55e); +} + +.setup-step-done p { + margin: 0; + font-size: 0.875rem; +} + +/* Step navigation */ +.setup-step-nav { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 1rem 1.25rem; + border-top: 1px solid var(--t-border-default, #d4d4d4); + background: var(--t-surface-sunken, #f5f5f5); +} + +.setup-step-nav-left, +.setup-step-nav-right { + display: flex; + align-items: center; +} + +.setup-step-back { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.5rem 0.75rem; + border: none; + background: none; + font: inherit; + font-size: 0.875rem; + color: var(--admin-text-muted); + cursor: pointer; + transition: color 0.15s; +} + +.setup-step-back:hover { + color: var(--t-text-primary, #171717); +} + +.setup-step-skip { + text-align: right; +} + +.setup-step-skip-btn { + padding: 0.5rem 1rem; + border: 1px solid var(--t-border-default, #d4d4d4); + border-radius: 0.375rem; + background: var(--t-surface-base, #fff); + font: inherit; + font-size: 0.875rem; + cursor: pointer; + transition: all 0.15s; +} + +.setup-step-skip-btn:hover { + border-color: var(--t-border-input, #a3a3a3); +} + +.setup-step-skip-note { + font-size: 0.75rem; + color: var(--admin-text-muted); + margin-top: 0.5rem; + max-width: 200px; +} + /* ── Dashboard launch checklist ── */ .admin-checklist { @@ -1834,6 +2118,37 @@ margin-bottom: 0; } +.admin-checklist-help { + display: inline-flex; + margin-left: 0.25rem; + color: var(--admin-text-muted); + vertical-align: middle; + + &:hover { color: var(--t-accent); } +} + +/* ── Status banners ── */ + +.admin-status-banner { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + font-size: 0.875rem; + font-weight: 500; +} + +.admin-status-banner-live { + background: oklch(0.95 0.05 145); + color: oklch(0.35 0.15 145); +} + +.admin-status-banner-setup { + background: oklch(0.95 0.05 250); + color: oklch(0.35 0.15 250); +} + /* ── Page editor ── */ .page-list { diff --git a/assets/css/shop/components.css b/assets/css/shop/components.css index de0ace7..4212cbb 100644 --- a/assets/css/shop/components.css +++ b/assets/css/shop/components.css @@ -2898,12 +2898,13 @@ } .coming-soon-logo { + display: flex; + justify-content: center; margin-bottom: 1.5rem; & img { max-height: 4rem; max-width: 12rem; - margin-inline: auto; object-fit: contain; } } @@ -2924,12 +2925,19 @@ } .coming-soon-admin-link { - font-size: 0.75rem; + position: fixed; + bottom: 1rem; + right: 1rem; + padding: 0.25rem 0.5rem; + font-size: 0.6875rem; color: var(--t-text-tertiary); text-decoration: none; + opacity: 0.5; + transition: opacity 0.15s ease; &:hover { color: var(--t-text-secondary); + opacity: 1; } } } diff --git a/berrypod_dev.db.backup-20260309-230545 b/berrypod_dev.db.backup-20260309-230545 new file mode 100644 index 0000000..0ee8469 Binary files /dev/null and b/berrypod_dev.db.backup-20260309-230545 differ diff --git a/berrypod_dev.db.backup-20260309-232703 b/berrypod_dev.db.backup-20260309-232703 new file mode 100644 index 0000000..0ee8469 Binary files /dev/null and b/berrypod_dev.db.backup-20260309-232703 differ diff --git a/docs/plans/onboarding-ux.md b/docs/plans/onboarding-ux.md index 2e4e100..e92835d 100644 --- a/docs/plans/onboarding-ux.md +++ b/docs/plans/onboarding-ux.md @@ -1,6 +1,6 @@ # Onboarding UX v2 -Status: In progress +Status: Complete Supersedes the original onboarding-ux plan. Based on usability testing session (March 2026) covering the setup wizard, launch checklist, email provider setup, and general onboarding flow. @@ -156,15 +156,15 @@ Increase input field border contrast to meet WCAG AA (3:1 minimum for UI compone | # | Task | Est | Status | |---|------|-----|--------| -| A | Simplify initial setup to account creation only | 1.5h | planned | -| B | Guided setup flow with progress bar | 4h | planned | -| C | Forgiving API key validation | 1.5h | planned | +| A | Simplify initial setup to account creation only | 1.5h | done | +| B | Guided setup flow with progress bar | 4h | done | +| C | Forgiving API key validation | 1.5h | done | | D | Email provider setup UX rework | 2h | done | | E | Contextual prompts for skipped steps | 2h | done | -| F | Dashboard checklist and messaging rework | 2h | planned | -| G | Coming soon page fixes (logo + admin link) | 30m | planned | -| H | External links UX (new tabs, icons, aria) | 1h | planned | -| I | Input styling — WCAG compliance | 1h | planned | +| F | Dashboard checklist and messaging rework | 2h | done | +| G | Coming soon page fixes (logo + admin link) | 30m | done | +| H | External links UX (new tabs, icons, aria) | 1h | done | +| I | Input styling — WCAG compliance | 1h | done | Total estimate: ~15.5h diff --git a/lib/berrypod/key_validation.ex b/lib/berrypod/key_validation.ex index ad3b727..78e447d 100644 --- a/lib/berrypod/key_validation.ex +++ b/lib/berrypod/key_validation.ex @@ -45,9 +45,11 @@ defmodule Berrypod.KeyValidation do @doc """ Validates a print provider API key (Printify, Printful, etc.). - Provider tokens are opaque, so we just check for empty/too-short values. + Checks known formats for each provider: + - Printify: 36-character UUID format + - Printful: typically 40+ characters """ - def validate_provider_key(key, _provider_type \\ nil) do + def validate_provider_key(key, provider_type \\ nil) do key = trim(key) cond do @@ -57,11 +59,58 @@ defmodule Berrypod.KeyValidation do String.length(key) < 10 -> {:error, "This looks too short for an API token. Check you copied the full value"} + true -> + validate_provider_format(key, provider_type) + end + end + + # Printify uses 36-character UUID format + defp validate_provider_format(key, "printify") do + # UUID format: 8-4-4-4-12 with hyphens = 36 chars + cond do + String.length(key) == 36 and uuid_format?(key) -> + {:ok, key} + + String.length(key) < 32 -> + {:error, + "This looks too short — Printify API keys are 36 characters. Find yours at Settings → Connections"} + + String.length(key) > 40 -> + {:error, + "This looks too long — Printify API keys are 36 characters. Make sure you're copying just the API key"} + + not uuid_format?(key) and String.length(key) in 32..40 -> + # Close enough in length but wrong format - might be missing hyphens + {:error, + "Printify API keys are in UUID format (like xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx). Check you copied the full key"} + true -> {:ok, key} end end + # Printful tokens are typically longer OAuth-style tokens + defp validate_provider_format(key, "printful") do + cond do + String.length(key) < 20 -> + {:error, "This looks too short for a Printful token. Find yours at Settings → API Access"} + + true -> + {:ok, key} + end + end + + # Unknown provider - basic validation + defp validate_provider_format(key, _provider_type), do: {:ok, key} + + # Check if string matches UUID format (8-4-4-4-12 hex with hyphens) + defp uuid_format?(key) do + String.match?( + key, + ~r/^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$/ + ) + end + @doc """ Validates an email provider API key or secret. diff --git a/lib/berrypod/theme/css_generator.ex b/lib/berrypod/theme/css_generator.ex index 93fdeef..79ece7d 100644 --- a/lib/berrypod/theme/css_generator.ex +++ b/lib/berrypod/theme/css_generator.ex @@ -72,7 +72,7 @@ defmodule Berrypod.Theme.CSSGenerator do --t-text-inverse: #ffffff; --t-border-default: #e5e5e5; --t-border-subtle: #f0f0f0; - --t-border-input: #8c8c8c; + --t-border-input: #767676; """ end @@ -89,7 +89,7 @@ defmodule Berrypod.Theme.CSSGenerator do --t-text-inverse: #ffffff; --t-border-default: #e7e0d8; --t-border-subtle: #f0ebe4; - --t-border-input: #8a827a; + --t-border-input: #706860; """ end @@ -106,7 +106,7 @@ defmodule Berrypod.Theme.CSSGenerator do --t-text-inverse: #ffffff; --t-border-default: #d4dce8; --t-border-subtle: #e8eff5; - --t-border-input: #7a8591; + --t-border-input: #606a7a; """ end @@ -123,7 +123,7 @@ defmodule Berrypod.Theme.CSSGenerator do --t-text-inverse: #171717; --t-border-default: #262626; --t-border-subtle: #1c1c1c; - --t-border-input: #707070; + --t-border-input: #808080; """ end diff --git a/lib/berrypod_web/live/admin/dashboard.ex b/lib/berrypod_web/live/admin/dashboard.ex index 98aed61..49b60c6 100644 --- a/lib/berrypod_web/live/admin/dashboard.ex +++ b/lib/berrypod_web/live/admin/dashboard.ex @@ -64,6 +64,24 @@ defmodule BerrypodWeb.Admin.Dashboard do + <%!-- Shop is live banner (not just now, but already live) --%> +
+ <.icon name="hero-check-circle" class="size-5" /> + Your shop is live +
+ + <%!-- Setup in progress message --%> +
+ <.icon name="hero-wrench-screwdriver" class="size-5" /> + Your admin account has been created. Continue the full setup below. +
+ <%!-- Launch checklist --%> <.launch_checklist :if={@show_checklist and !@just_went_live} @@ -222,6 +240,16 @@ defmodule BerrypodWeb.Admin.Dashboard do

{item.hint} + + <.icon name="hero-question-mark-circle-mini" class="size-4" /> + Help (opens in new window) +

- "You'll see the order in Orders when it works." + hint: "Use card 4242 4242 4242 4242 with any future expiry and CVC.", + optional: true }, %{key: :site_live, label: "Go live"} ] diff --git a/lib/berrypod_web/live/admin/providers/form.ex b/lib/berrypod_web/live/admin/providers/form.ex index b449d56..58f8210 100644 --- a/lib/berrypod_web/live/admin/providers/form.ex +++ b/lib/berrypod_web/live/admin/providers/form.ex @@ -40,14 +40,33 @@ defmodule BerrypodWeb.Admin.Providers.Form do @impl true def handle_event("validate", %{"provider_connection" => params}, socket) do - form = + api_key = params["api_key"] || "" + provider_type = socket.assigns.provider_type + + # Build base changeset + changeset = socket.assigns.connection |> ProviderConnection.changeset(params) |> Map.put(:action, :validate) - |> to_form() + + # Add key format validation error if key is present + form = + if api_key != "" do + case KeyValidation.validate_provider_key(api_key, provider_type) do + {:ok, _} -> + to_form(changeset) + + {:error, message} -> + changeset + |> Ecto.Changeset.add_error(:api_key, message) + |> to_form() + end + else + to_form(changeset) + end # Store api_key separately since changeset encrypts it immediately - {:noreply, assign(socket, form: form, pending_api_key: params["api_key"])} + {:noreply, assign(socket, form: form, pending_api_key: api_key)} end @impl true diff --git a/lib/berrypod_web/live/admin/providers/form.html.heex b/lib/berrypod_web/live/admin/providers/form.html.heex index d796664..399d449 100644 --- a/lib/berrypod_web/live/admin/providers/form.html.heex +++ b/lib/berrypod_web/live/admin/providers/form.html.heex @@ -51,6 +51,7 @@ else: "Paste your key here" } autocomplete="off" + phx-debounce="blur" /> <%= if @live_action == :edit do %> diff --git a/lib/berrypod_web/live/setup/onboarding.ex b/lib/berrypod_web/live/setup/onboarding.ex index 0c8eb24..5075e0b 100644 --- a/lib/berrypod_web/live/setup/onboarding.ex +++ b/lib/berrypod_web/live/setup/onboarding.ex @@ -5,6 +5,14 @@ defmodule BerrypodWeb.Setup.Onboarding do alias Berrypod.Providers.Provider alias Berrypod.Stripe.Setup, as: StripeSetup + # Steps in the guided flow (after account creation): + # :intro - explains what's needed + # :provider - connect print provider + # :stripe - connect Stripe payments + # :email - set up email provider + + @guided_steps [:intro, :provider, :stripe, :email] + # ── Mount ── @impl true @@ -37,18 +45,15 @@ 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 + # Determine which guided step to show (for logged-in users) + guided_step = determine_guided_step(setup) socket |> assign(:page_title, "Set up your shop") |> assign(:setup, setup) |> assign(:logged_in?, logged_in?) - |> assign(:current_step, current_step) + |> assign(:guided_step, guided_step) + |> assign(:guided_steps, @guided_steps) # Secret gate |> assign(:require_secret?, Setup.require_setup_secret?()) |> assign(:secret_verified, false) @@ -56,7 +61,10 @@ defmodule BerrypodWeb.Setup.Onboarding do # Account (card 1) |> assign( :account_form, - to_form(%{"email" => "", "password" => "", "shop_name" => ""}, as: :account) + to_form( + %{"email" => "", "password" => "", "password_confirmation" => "", "shop_name" => ""}, + as: :account + ) ) # Provider (card 2) |> assign(:providers, Provider.all()) @@ -69,6 +77,30 @@ defmodule BerrypodWeb.Setup.Onboarding do |> assign(:stripe_connecting, false) end + # Determine which guided step to show based on what's already configured + defp determine_guided_step(setup) do + cond do + # Show intro on first visit after account creation + not setup.provider_connected and not setup.stripe_connected and + not setup.email_configured -> + :intro + + # Resume at first incomplete step + not setup.provider_connected -> + :provider + + not setup.stripe_connected -> + :stripe + + not setup.email_configured -> + :email + + # All done - will redirect to dashboard + true -> + :email + end + end + # ── Events: Secret gate ── @impl true @@ -80,19 +112,69 @@ defmodule BerrypodWeb.Setup.Onboarding do end end + # ── Events: Guided flow navigation ── + + def handle_event("start_setup", _params, socket) do + {:noreply, assign(socket, :guided_step, :provider)} + end + + def handle_event("go_to_step", %{"step" => step}, socket) do + step = String.to_existing_atom(step) + + if step in @guided_steps do + {:noreply, assign(socket, :guided_step, step)} + else + {:noreply, socket} + end + end + + def handle_event("skip_step", _params, socket) do + next_step = next_guided_step(socket.assigns.guided_step) + handle_step_completion(socket, next_step) + end + + def handle_event("go_back", _params, socket) do + prev_step = prev_guided_step(socket.assigns.guided_step) + {:noreply, assign(socket, :guided_step, prev_step)} + end + # ── Events: Account ── + def handle_event("validate_account", %{"account" => params}, socket) do + errors = validate_account_fields(params) + + form = + to_form(params, + as: :account, + errors: errors, + action: if(errors != [], do: :validate) + ) + + {:noreply, assign(socket, :account_form, form)} + end + def handle_event("create_account", %{"account" => params}, socket) do email = params["email"] password = params["password"] + password_confirmation = params["password_confirmation"] shop_name = String.trim(params["shop_name"] || "") - cond do - shop_name == "" -> - {:noreply, put_flash(socket, :error, "Please enter a shop name")} + errors = validate_account_fields(params) - email == "" -> - {:noreply, put_flash(socket, :error, "Please enter your email address")} + cond do + errors != [] -> + form = to_form(params, as: :account, errors: errors, action: :validate) + {:noreply, assign(socket, :account_form, form)} + + password != password_confirmation -> + form = + to_form(params, + as: :account, + errors: [password_confirmation: {"Passwords don't match", []}], + action: :validate + ) + + {:noreply, assign(socket, :account_form, form)} true -> Settings.put_setting("site_name", shop_name, "string") @@ -149,19 +231,13 @@ defmodule BerrypodWeb.Setup.Onboarding do {:ok, connection} -> setup = Setup.setup_status() - 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 + {:noreply, + socket + |> assign(:provider_connecting, false) + |> assign(:provider_conn, connection) + |> assign(:setup, setup) + |> assign(:guided_step, :stripe) + |> put_flash(:info, "Connected! Product sync started in the background.")} {:error, :no_api_key} -> form = @@ -192,8 +268,46 @@ defmodule BerrypodWeb.Setup.Onboarding do end end + # Validate provider key on blur for fast feedback + def handle_event("validate_provider", %{"provider" => %{"api_key" => api_key}}, socket) do + type = socket.assigns.selected_provider + + form = + case KeyValidation.validate_provider_key(api_key, type) do + {:ok, _} -> + to_form(%{"api_key" => api_key}, as: :provider) + + {:error, message} -> + to_form(%{"api_key" => api_key}, + as: :provider, + errors: [api_key: {message, []}], + action: :validate + ) + end + + {:noreply, assign(socket, :provider_form, form)} + end + # ── Events: Stripe ── + # Validate Stripe key on blur for fast feedback + def handle_event("validate_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do + form = + case KeyValidation.validate_stripe_key(api_key) do + {:ok, _} -> + to_form(%{"api_key" => api_key}, as: :stripe) + + {:error, message} -> + to_form(%{"api_key" => api_key}, + as: :stripe, + errors: [api_key: {message, []}], + action: :validate + ) + end + + {:noreply, assign(socket, :stripe_form, form)} + end + def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do case KeyValidation.validate_stripe_key(api_key) do {:error, message} -> @@ -213,18 +327,12 @@ defmodule BerrypodWeb.Setup.Onboarding do {:ok, _result} -> setup = Setup.setup_status() - 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 + {:noreply, + socket + |> assign(:stripe_connecting, false) + |> assign(:setup, setup) + |> assign(:guided_step, :email) + |> put_flash(:info, "Stripe connected")} {:error, message} -> form = @@ -242,6 +350,37 @@ defmodule BerrypodWeb.Setup.Onboarding do end end + # ── Navigation helpers ── + + defp next_guided_step(current) do + case current do + :intro -> :provider + :provider -> :stripe + :stripe -> :email + :email -> :done + end + end + + defp prev_guided_step(current) do + case current do + :provider -> :intro + :stripe -> :provider + :email -> :stripe + _ -> current + end + end + + defp handle_step_completion(socket, :done) do + {:noreply, + socket + |> put_flash(:info, "Setup complete! Here's your launch checklist.") + |> push_navigate(to: ~p"/admin")} + end + + defp handle_step_completion(socket, next_step) do + {:noreply, assign(socket, :guided_step, next_step)} + end + # ── Render ── @impl true @@ -288,16 +427,16 @@ defmodule BerrypodWeb.Setup.Onboarding do <% else %> - <%!-- All three setup cards --%> -

+ <%!-- Account creation (before login) --%> +
<.section_card title="Set up your account" number={1} - done={@logged_in?} - summary={account_summary(assigns)} + done={false} + summary={nil} >

Name your shop and create the admin account.

- <.form for={@account_form} phx-submit="create_account"> + <.form for={@account_form} phx-submit="create_account" phx-change="validate_account"> <.input field={@account_form[:shop_name]} type="text" @@ -305,8 +444,9 @@ defmodule BerrypodWeb.Setup.Onboarding do placeholder="e.g. Acme Prints" autocomplete="off" required - phx-mounted={@current_step == 1 && JS.focus()} + phx-mounted={JS.focus()} /> +

You can change this later

<.input field={@account_form[:email]} type="email" @@ -322,48 +462,149 @@ defmodule BerrypodWeb.Setup.Onboarding do autocomplete="new-password" required /> + <.input + field={@account_form[:password_confirmation]} + type="password" + label="Confirm password" + autocomplete="new-password" + required + />
<.button phx-disable-with="Creating account...">Create account
- - <.section_card - title="Connect a print provider" - number={2} - done={@setup.provider_connected} - summary={provider_summary(assigns)} - > - <.provider_section - providers={@providers} - selected={@selected_provider} - form={@provider_form} - connecting={@provider_connecting} - /> - - - <.section_card - title="Connect payments" - number={3} - done={@setup.stripe_connected} - summary={stripe_summary(assigns)} - > - <.stripe_section - form={@stripe_form} - connecting={@stripe_connecting} - focus={@current_step == 3} - /> -
- <%!-- All done --%> -
- <.icon name="hero-check-badge" class="setup-complete-icon" /> -

You're all set

-

Head to the dashboard to sync products, customise your theme, and go live.

- <.link navigate={~p"/admin"} class="admin-btn admin-btn-primary"> - Go to dashboard - + <%!-- Guided setup flow (after login) --%> +
+ <%!-- Progress bar --%> + <.progress_bar + current_step={@guided_step} + setup={@setup} + /> + + <%!-- Intro screen --%> +
+
+

+ Berrypod connects your print-on-demand products to your own online shop. + To get fully set up, you'll need three things: +

+
    +
  • + <.icon name="hero-cube" class="setup-intro-icon" /> +
    + A print provider account + (like Printify or Printful) to make and ship your products +
    +
  • +
  • + <.icon name="hero-credit-card" class="setup-intro-icon" /> +
    + A Stripe account + to accept payments from customers +
    +
  • +
  • + <.icon name="hero-envelope" class="setup-intro-icon" /> +
    + An email provider account + so your shop can send order confirmations and shipping updates +
    +
  • +
+

+ Don't worry if you don't have all of these yet — you can skip any step and set it up later. +

+
+
+ <.button phx-click="start_setup"> + Let's get started + +
+
+ + <%!-- Step 1: Provider --%> +
+ <.step_header + number={1} + title="Connect a print provider" + done={@setup.provider_connected} + /> +
+ <.provider_section + providers={@providers} + selected={@selected_provider} + form={@provider_form} + connecting={@provider_connecting} + /> +
+ <.step_nav + step={:provider} + can_skip={true} + skip_note="You won't be able to import products until you connect a provider." + /> +
+ + <%!-- Step 2: Stripe --%> +
+ <.step_header + number={2} + title="Connect Stripe for payments" + done={@setup.stripe_connected} + /> +
+ <.stripe_section + form={@stripe_form} + connecting={@stripe_connecting} + focus={true} + /> +
+ <.step_nav + step={:stripe} + can_skip={true} + skip_note="Customers won't be able to checkout until you connect Stripe." + /> +
+ + <%!-- Step 3: Email --%> +
+ <.step_header + number={3} + title="Set up email" + done={@setup.email_configured} + /> +
+

+ Configure an email provider so your shop can send order confirmations, + shipping updates, and newsletters. +

+ <%= if @setup.email_configured do %> +
+ <.icon name="hero-check-circle" class="setup-step-done-icon" /> +

Email is already configured.

+
+
+ <.button phx-click="skip_step"> + Continue + +
+ <% else %> +

+ <.link navigate={~p"/admin/settings/email"} class="setup-link"> + Set up email in settings + +

+ <% end %> +
+ <.step_nav + step={:email} + can_skip={true} + skip_note="Order confirmations and shipping updates won't be sent." + finish_button={true} + /> +
<% end %>
@@ -375,7 +616,7 @@ defmodule BerrypodWeb.Setup.Onboarding do attr :title, :string, required: true attr :number, :integer, required: true attr :done, :boolean, required: true - attr :summary, :string, default: nil + attr :summary, :any, default: nil slot :inner_block, required: true @@ -404,6 +645,120 @@ defmodule BerrypodWeb.Setup.Onboarding do """ end + # ── Progress bar component ── + + attr :current_step, :atom, required: true + attr :setup, :map, required: true + + defp progress_bar(assigns) do + steps = [ + %{key: :provider, label: "Provider", done: assigns.setup.provider_connected}, + %{key: :stripe, label: "Payments", done: assigns.setup.stripe_connected}, + %{key: :email, label: "Email", done: assigns.setup.email_configured} + ] + + current_index = + case assigns.current_step do + :intro -> -1 + :provider -> 0 + :stripe -> 1 + :email -> 2 + _ -> -1 + end + + assigns = assign(assigns, steps: steps, current_index: current_index) + + ~H""" + + """ + end + + # ── Step header component ── + + attr :number, :integer, required: true + attr :title, :string, required: true + attr :done, :boolean, default: false + + defp step_header(assigns) do + ~H""" +
+ + <%= if @done do %> + <.icon name="hero-check-mini" class="size-5" /> + <% else %> + {@number} + <% end %> + +

{@title}

+
+ """ + end + + # ── Step navigation component ── + + attr :step, :atom, required: true + attr :can_skip, :boolean, default: false + attr :skip_note, :string, default: nil + attr :finish_button, :boolean, default: false + + defp step_nav(assigns) do + ~H""" +
+
+ <%= if @step != :provider do %> + + <% end %> +
+
+ <%= if @can_skip do %> +
+ + <%= if @skip_note do %> +

{@skip_note}

+ <% end %> +
+ <% end %> +
+
+ """ + end + # ── Provider section ── attr :providers, :list, required: true @@ -437,13 +792,14 @@ defmodule BerrypodWeb.Setup.Onboarding do

- <.form for={@form} phx-submit="connect_provider"> + <.form for={@form} phx-submit="connect_provider" phx-change="validate_provider"> <.input field={@form[:api_key]} type="text" label="API token" placeholder="Paste your token here" autocomplete="off" + phx-debounce="blur" />
@@ -473,7 +829,7 @@ defmodule BerrypodWeb.Setup.Onboarding do

- <.form for={@form} phx-submit="connect_stripe"> + <.form for={@form} phx-submit="connect_stripe" phx-change="validate_stripe"> <.input field={@form[:api_key]} type="text" @@ -481,6 +837,7 @@ defmodule BerrypodWeb.Setup.Onboarding do autocomplete="off" placeholder="sk_test_... or sk_live_..." phx-mounted={@focus && JS.focus()} + phx-debounce="blur" />
<.button phx-disable-with="Connecting..."> @@ -539,4 +896,49 @@ defmodule BerrypodWeb.Setup.Onboarding do end end) end + + # Live validation for account creation form + defp validate_account_fields(params) do + errors = [] + + shop_name = String.trim(params["shop_name"] || "") + email = String.trim(params["email"] || "") + password = params["password"] || "" + password_confirmation = params["password_confirmation"] || "" + + errors = + if shop_name == "" and params["shop_name"] != "" do + [{:shop_name, {"Shop name can't be blank", []}} | errors] + else + errors + end + + errors = + if email != "" and not valid_email?(email) do + [{:email, {"Please enter a valid email address", []}} | errors] + else + errors + end + + errors = + if password != "" and String.length(password) < 12 do + [{:password, {"Password must be at least 12 characters", []}} | errors] + else + errors + end + + errors = + if password_confirmation != "" and password != password_confirmation do + [{:password_confirmation, {"Passwords don't match", []}} | errors] + else + errors + end + + Enum.reverse(errors) + end + + defp valid_email?(email) do + # Basic email format check + String.match?(email, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/) + end end diff --git a/test/berrypod/key_validation_test.exs b/test/berrypod/key_validation_test.exs index 02fd1ef..cbe1ca9 100644 --- a/test/berrypod/key_validation_test.exs +++ b/test/berrypod/key_validation_test.exs @@ -61,15 +61,17 @@ defmodule Berrypod.KeyValidationTest do end end - describe "validate_provider_key/2" do - test "accepts reasonable key" do - assert {:ok, "abcdef1234567890abcdef"} = - KeyValidation.validate_provider_key("abcdef1234567890abcdef", "printify") + describe "validate_provider_key/2 - Printify" do + @printify_uuid "12345678-1234-1234-1234-123456789012" + + test "accepts valid UUID format key" do + assert {:ok, @printify_uuid} = + KeyValidation.validate_provider_key(@printify_uuid, "printify") end test "strips whitespace" do - assert {:ok, "abcdef1234567890abcdef"} = - KeyValidation.validate_provider_key(" abcdef1234567890abcdef ", "printful") + assert {:ok, @printify_uuid} = + KeyValidation.validate_provider_key(" #{@printify_uuid} ", "printify") end test "rejects empty key" do @@ -87,9 +89,41 @@ defmodule Berrypod.KeyValidationTest do assert msg =~ "too short" end + test "rejects too-long key" do + long_key = String.duplicate("a", 50) + assert {:error, msg} = KeyValidation.validate_provider_key(long_key, "printify") + assert msg =~ "too long" + end + + test "gives helpful error for non-UUID format" do + assert {:error, msg} = + KeyValidation.validate_provider_key("12345678123412341234123456789012", "printify") + + assert msg =~ "UUID format" + end + end + + describe "validate_provider_key/2 - Printful" do + test "accepts reasonable length key" do + key = String.duplicate("x", 40) + assert {:ok, ^key} = KeyValidation.validate_provider_key(key, "printful") + end + + test "strips whitespace" do + key = String.duplicate("x", 40) + assert {:ok, ^key} = KeyValidation.validate_provider_key(" #{key} ", "printful") + end + + test "rejects too-short key" do + assert {:error, msg} = KeyValidation.validate_provider_key("short", "printful") + assert msg =~ "too short" + end + end + + describe "validate_provider_key/2 - generic" do test "works without provider type" do - assert {:ok, "abcdef1234567890abcdef"} = - KeyValidation.validate_provider_key("abcdef1234567890abcdef") + key = String.duplicate("x", 20) + assert {:ok, ^key} = KeyValidation.validate_provider_key(key) end end diff --git a/test/berrypod_web/live/admin/dashboard_test.exs b/test/berrypod_web/live/admin/dashboard_test.exs index ef63600..afaac06 100644 --- a/test/berrypod_web/live/admin/dashboard_test.exs +++ b/test/berrypod_web/live/admin/dashboard_test.exs @@ -28,9 +28,9 @@ defmodule BerrypodWeb.Admin.DashboardTest do assert html =~ "Launch checklist" assert html =~ "Connect a print provider" - assert html =~ "Connect Stripe" - assert html =~ "Sync your products" - assert html =~ "Customise your theme" + assert html =~ "Connect Stripe for payments" + assert html =~ "Import products" + assert html =~ "Customise your shop" assert html =~ "Go live" end @@ -43,16 +43,16 @@ defmodule BerrypodWeb.Admin.DashboardTest do test "collapse and expand checklist", %{conn: conn} do {:ok, view, html} = live(conn, ~p"/admin") - assert html =~ "Sync your products" + assert html =~ "Import products" # Collapse html = render_click(view, "toggle_checklist") - refute html =~ "Sync your products" + refute html =~ "Import products" assert html =~ "Launch checklist" # Expand html = render_click(view, "toggle_checklist") - assert html =~ "Sync your products" + assert html =~ "Import products" end test "go live button works", %{conn: conn} do diff --git a/test/berrypod_web/live/admin/providers_test.exs b/test/berrypod_web/live/admin/providers_test.exs index 30856f8..849bdfb 100644 --- a/test/berrypod_web/live/admin/providers_test.exs +++ b/test/berrypod_web/live/admin/providers_test.exs @@ -174,11 +174,14 @@ defmodule BerrypodWeb.Admin.ProvidersTest do {:ok, view, _html} = live(conn, ~p"/admin/providers/new") + # Printify uses 36-character UUID format keys + valid_uuid_key = "12345678-1234-1234-1234-123456789012" + {:ok, _view, html} = view |> form("#provider-form", %{ "provider_connection" => %{ - "api_key" => "test_key_123" + "api_key" => valid_uuid_key } }) |> render_submit() diff --git a/test/berrypod_web/live/setup/onboarding_test.exs b/test/berrypod_web/live/setup/onboarding_test.exs index 3390059..94e5dec 100644 --- a/test/berrypod_web/live/setup/onboarding_test.exs +++ b/test/berrypod_web/live/setup/onboarding_test.exs @@ -57,12 +57,13 @@ defmodule BerrypodWeb.Setup.OnboardingTest do end describe "fresh install (no admin)" do - test "shows all three cards with email form active", %{conn: conn} do + test "shows only account card (simplified setup)", %{conn: conn} do {:ok, _view, html} = live(conn, ~p"/setup") assert html =~ "Set up your account" - assert html =~ "Connect a print provider" - assert html =~ "Connect payments" + # Provider and Stripe cards are hidden until after account creation + refute html =~ "Connect a print provider" + refute html =~ "Connect payments" end test "creating account auto-confirms and redirects to login", %{conn: conn} do @@ -73,7 +74,8 @@ defmodule BerrypodWeb.Setup.OnboardingTest do account: %{ shop_name: "Test Shop", email: "admin@example.com", - password: "valid_password_123" + password: "valid_password_123", + password_confirmation: "valid_password_123" } ) |> render_submit() @@ -87,26 +89,31 @@ defmodule BerrypodWeb.Setup.OnboardingTest do describe "configure (logged in)" do setup :register_and_log_in_user - test "shows provider and stripe steps", %{conn: conn, user: user} do + test "shows guided setup flow", %{conn: conn} do {:ok, _view, html} = live(conn, ~p"/setup") - assert html =~ user.email - assert html =~ "Connect a print provider" - assert html =~ "Connect payments" + # Guided flow shows progress bar + assert html =~ "Setup progress" + # Shows one of the setup steps or intro + assert html =~ "Connect" or html =~ "Let's get started" end - test "shows provider cards", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/setup") + test "clicking start shows provider step", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/setup") + html = render_click(view, "start_setup") + + assert html =~ "Connect a print provider" assert html =~ "Printify" assert html =~ "Printful" - assert html =~ "Gelato" - assert html =~ "Coming soon" end test "selecting a provider shows the API key form", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/setup") + # First go to provider step + render_click(view, "start_setup") + html = view |> form(~s(form[phx-change="select_provider"]), %{provider_select: %{type: "printify"}}) @@ -116,11 +123,15 @@ defmodule BerrypodWeb.Setup.OnboardingTest do assert html =~ "Printify" end - test "shows stripe form", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/setup") + test "skipping provider step shows stripe step", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/setup") + + # Start and skip provider + render_click(view, "start_setup") + html = render_click(view, "skip_step") assert html =~ "Secret key" - assert html =~ "Connect payments" + assert html =~ "Connect Stripe" end end