berrypod/docs/plans/notification-overhaul.md
jamey dbcecc7878
All checks were successful
deploy / deploy (push) Successful in 37s
update notification overhaul plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:30:29 +00:00

13 KiB

Notification system overhaul

Status: Planned

Replace floating toast/flash notifications with inline feedback and persistent top banners. Based on usability testing (March 2026) — flash messages overlay UI, auto-dismiss means users miss them, and the overall pattern feels messy.

Current state

144 put_flash calls across the codebase (59 :info, 34 :error, rest multi-line). Two separate toast implementations:

  • Admin (core_components.ex.admin-toast): fixed top-right, 20rem wide, click to dismiss via JS.push("lv:clear-flash") + JS.hide
  • Shop (shop_components/content.ex.shop-flash): fixed top-right, theme-aware, CSS slide-in, click anywhere to dismiss

Both are overlays — fixed-position, outside document flow, disappear on click.

Existing inline patterns already in the codebase:

  • <.error> component — red text below form fields (.admin-error) via changeset errors
  • Static .admin-alert boxes — persistent info/error in auth/setup pages
  • .admin-checklist-banner — contextual "back to checklist" banners
  • .admin-banner-warning — full-width warning banner (email settings)

Top files by flash count: settings.ex (17), onboarding.ex (9), campaign_form.ex (9), pages/editor.ex (8), theme/index.ex (6), media.ex (6)

New approach: two layers

1. Inline feedback (contextual)

Feedback next to the field or action that triggered it. Persistent until state changes.

  • Success: green tick + "Saved" label next to save button. Auto-clears after 3s (LiveView only).
  • Error: red border on field + error text below. Stays until fixed. Works identically with and without JS (changeset errors re-render the form).
  • Loading: subtle spinner + "Saving..." / "Syncing..." while an async action is in-flight.

Best for: form saves, field validation, toggle changes, individual actions.

2. Top banner (page-level)

For outcomes not tied to a specific field.

  • Full-width coloured bar: green (success), red (error), amber (warning)
  • Document flow — pushes content down, not an overlay
  • Persistent until dismissed (close button). No auto-dismiss.
  • Positioned at top of content area, below header/nav

Best for: "Products synced", "Page deleted", auth messages, operation outcomes.

3. No floating toasts

The fixed-position overlay toast pattern is removed entirely.

Progressive enhancement

Scenario No JS (controller POST → redirect) With JS (LiveView)
Save success Banner via flash (PRG redirect) Inline "Saved" tick next to button
Validation error Inline field errors (controller re-renders form with changeset) Inline field errors (LiveView validates on change/submit)
Page-level outcome Banner via flash Banner via flash
Connection lost N/A (no WebSocket) Banner via phx-disconnected

Banners are the baseline that works everywhere. Inline feedback is a progressive enhancement on top — LiveView pages get richer feedback, but no-JS pages still get inline validation errors via changeset re-rendering and success banners via flash.

Component spec

<.banner> — replaces <.flash>

Reuses the existing flash infrastructure. Migration path: change CSS + move position in layout. Zero LiveView code changes needed — all 144 put_flash calls continue working.

Layout change (admin):

<%!-- Move from AFTER the layout div (current line 228) to INSIDE .admin-layout-content, above <main> --%>
<div class="admin-layout-content">
  <header class="admin-topbar">...</header>
  <.flash_group flash={@flash} />   <%!-- NEW POSITION --%>
  <main class="admin-main">...</main>
</div>

Component change (core_components.ex):

<%!-- Change wrapper class from .admin-toast (fixed position) to .admin-banner (document flow) --%>
<div :if={msg = ...} id={@id} class="admin-banner" ...>
  <div class={["admin-banner-inner", kind_class(@kind)]}>
    <icon /> <p>{msg}</p> <close button />
  </div>
</div>

CSS (evolves from existing .admin-banner-warning):

/* replaces .admin-toast (fixed position) */
.admin-banner {
  /* document flow, no position: fixed */
}

.admin-banner-inner {
  display: flex;
  align-items: flex-start;
  gap: 0.75rem;
  padding: 0.75rem 1rem;
  font-size: 0.875rem;
  line-height: 1.5;
}

.admin-banner-info {
  background: color-mix(in oklch, var(--t-status-info) 12%, var(--t-surface-base));
  color: var(--t-status-info);
  border-bottom: 1px solid color-mix(in oklch, var(--t-status-info) 25%, var(--t-surface-base));
}

.admin-banner-error {
  background: color-mix(in oklch, var(--t-status-error) 12%, var(--t-surface-base));
  color: var(--t-status-error);
  border-bottom: 1px solid color-mix(in oklch, var(--t-status-error) 25%, var(--t-surface-base));
}

/* .admin-banner-warning already exists and matches this pattern */

No-JS: Works perfectly. Banner is HTML in document flow — displays without JS. Close button uses phx-click so without LiveView the banner stays visible (desired — persistent by design). Flash clears naturally on next navigation.

Shop layout: Same treatment — move <.shop_flash_group> from fixed overlay to document flow inside the content area.

<.inline_feedback> — new component

Lightweight status indicator placed next to a button or form section.

Component (core_components.ex):

attr :status, :atom, values: [:idle, :saving, :saved, :error], default: :idle
attr :message, :string, default: nil

def inline_feedback(assigns) do
  ~H"""
  <span
    :if={@status != :idle}
    class={["admin-inline-feedback", "admin-inline-feedback-#{@status}"]}
    role={@status == :error && "alert"}
  >
    <.icon :if={@status == :saving} name="hero-arrow-path" class="size-4 motion-safe:animate-spin" />
    <.icon :if={@status == :saved} name="hero-check" class="size-4" />
    <.icon :if={@status == :error} name="hero-exclamation-circle" class="size-4" />
    <span>{feedback_text(@status, @message)}</span>
  </span>
  """
end

defp feedback_text(:saving, _), do: "Saving..."
defp feedback_text(:saved, nil), do: "Saved"
defp feedback_text(:saved, msg), do: msg
defp feedback_text(:error, nil), do: "Something went wrong"
defp feedback_text(:error, msg), do: msg
defp feedback_text(:idle, _), do: nil

CSS:

.admin-inline-feedback {
  display: inline-flex;
  align-items: center;
  gap: 0.375rem;
  font-size: 0.875rem;
  line-height: 1.5;
}

.admin-inline-feedback-saving {
  color: var(--admin-text-soft);
}

.admin-inline-feedback-saved {
  color: var(--t-status-success, oklch(0.55 0.15 145));
}

.admin-inline-feedback-error {
  color: var(--t-status-error);
  font-weight: 600;
}

Usage pattern (LiveView):

# Mount
assign(socket, save_status: :idle, save_error: nil)

# Save handler
def handle_event("save", params, socket) do
  case Context.save(params) do
    {:ok, _} ->
      Process.send_after(self(), :clear_save_status, 3000)
      {:noreply, assign(socket, :save_status, :saved)}
    {:error, reason} ->
      {:noreply, assign(socket, save_status: :error, save_error: reason)}
  end
end

def handle_info(:clear_save_status, socket) do
  {:noreply, assign(socket, :save_status, :idle)}
end

Template:

<div class="admin-form-actions">
  <.button type="submit" variant="primary">Save</.button>
  <.inline_feedback status={@save_status} message={@save_error} />
</div>

Connection error handling

The client-error and server-error flashes (LiveView disconnect/reconnect) become banners too. Suppress them during intentional navigation to avoid a brief flash on refresh:

JS (app.js):

window.addEventListener("beforeunload", () => {
  document.body.classList.add("navigating-away")
})

CSS:

.navigating-away #flash-group { display: none; }

beforeunload fires on refresh/navigation but NOT on genuine connection drops, so the banner still shows when actually needed.

Migration categories

Category 1: Form saves → inline feedback

Current flash Location Replacement
"Theme saved successfully" Theme editor Inline "Saved" tick next to save button
"Email settings saved" Email settings Inline "Saved" tick near save
"Settings saved" Provider form Inline "Saved" tick near save
"Navigation saved" Navigation editor Inline "Saved" tick near save
"Page settings saved" Page editor Inline "Saved" tick near save
"Metadata updated" Media library Inline "Saved" tick on metadata form
"Product updated" Product show Inline "Saved" tick near changed field

Category 2: Validation errors → inline field errors

These flash-based validation messages become proper inline field errors. Works with and without JS — controller re-renders form with changeset errors, LiveView validates on change.

Current flash Location Replacement
"Please enter your API token" Setup wizard Red border + error on API key field
"Missing required fields: {labels}" Email settings Red border on each missing field
"Please enter your Stripe secret key" Settings Red border + error on Stripe key field
"Please fill in all required fields" Contact form Red border on each empty required field
"Please enter a valid email address" Newsletter Red border + error on email field
"Password must be at least 12 characters" Setup recover Red border + error on password field
"Please enter a shop name" / "email" Setup Red border on relevant field

Category 3: State changes → top banner

Page-level outcomes, kept as put_flash → render as banner instead of toast:

Current flash Location
"Shop is now live" / "Shop taken offline" Settings
"Connected to {provider}!" Provider form
"Email provider disconnected" Email settings
"Stripe connected and webhook endpoint created" Settings
"Provider connection deleted" Providers index
"Page deleted" / "Page created" Pages index

Category 4: Operation outcomes → top banner

Current flash Location
"Sync started for {name}" Providers/settings
"Connected! Product sync started in the background." Setup
"Submission retry enqueued" Activity log
"Image uploaded" / "Image deleted" Media library
"Campaign is being sent!" Newsletter
"Test email sent to {email}" Email settings
"Message sent! We'll get back to you soon." Contact

Category 5: Auth/system → top banner

Current flash Location
"Welcome back!" Dashboard
"Logged out successfully" Login page
"Invalid email or password" Login (could be inline under form)
"User confirmed successfully" Email confirm

Category 6: Cart feedback → inline

Current flash Replacement
"Added to basket" Brief inline confirmation near add button / cart icon badge update
"Removed from basket" Item visually removed, no separate message

Implementation plan

Phase 1: Build components (~2h)

Build both components. Convert flash rendering from toast to banner. No behaviour changes — all existing put_flash calls work, just render differently.

  1. Restyle <.flash> from .admin-toast (fixed) to .admin-banner (document flow)
  2. Move <.flash_group> in admin layout from after the div to inside content area
  3. Same for shop layout — restyle .shop-flash to document flow
  4. Build <.inline_feedback> component in core_components.ex
  5. Add beforeunload suppression for connection error banners
  6. Delete old .admin-toast and .shop-flash-group (fixed-position) CSS

Phase 2: Migrate admin forms (~3h)

Replace put_flash(:info, "Saved") with inline feedback on the highest-traffic pages:

  • Theme editor
  • Page editor
  • Settings
  • Email settings
  • Provider forms

Each migration: add :save_status assign, swap put_flash for assign(:save_status, :saved), add <.inline_feedback> to template, add Process.send_after auto-clear.

Phase 3: Migrate remaining admin pages (~2h)

  • Media library
  • Products
  • Activity log
  • Newsletter
  • Redirects
  • Navigation

Phase 4: Migrate shop pages (~2h)

  • Cart (add/remove → inline feedback)
  • Contact form (success → replace form with success state)
  • Checkout errors
  • Auth flows (login, registration, confirmation)

Phase 5: Migrate setup wizard (~1h)

Update setup flow — ties into onboarding-ux v2 plan. If onboarding tasks A/B are done first, this phase applies inline feedback to the new guided flow.

Task breakdown

# Task Est Status
1 Build banner + inline feedback components, restyle flash rendering 2h planned
2 Migrate admin forms (theme, pages, settings, email, providers) 3h planned
3 Migrate remaining admin pages (media, products, activity, newsletter, redirects, nav) 2h planned
4 Migrate shop pages (cart, contact, checkout, auth) 2h planned
5 Migrate setup wizard notifications 1h planned

Total estimate: ~10h