2026-03-08 07:55:55 +00:00
# Notification system
2026-03-03 22:45:41 +00:00
2026-03-08 07:55:55 +00:00
Status: Complete
2026-03-03 22:45:41 +00:00
2026-03-08 07:55:55 +00:00
This document describes the notification and feedback patterns used throughout the app. Follow these rules when implementing new features or modifying existing ones.
2026-03-03 22:45:41 +00:00
2026-03-08 07:55:55 +00:00
## Core principles
2026-03-03 22:45:41 +00:00
2026-03-08 07:55:55 +00:00
1. **No floating toasts** — fixed-position overlays are removed entirely
2. **Inline feedback for form saves** — contextual, next to the action
3. **Banners for page-level outcomes** — document flow, persistent until dismissed
4. **Progressive enhancement** — works without JS, enhanced with LiveView
2026-03-05 15:30:29 +00:00
2026-03-08 07:55:55 +00:00
## Components
2026-03-05 15:30:29 +00:00
2026-03-08 07:55:55 +00:00
### `<.inline_feedback>` — contextual save status
2026-03-05 15:30:29 +00:00
2026-03-08 07:55:55 +00:00
Used for form saves that stay on the same page. Shows status next to the save button.
2026-03-05 15:30:29 +00:00
```heex
2026-03-08 07:55:55 +00:00
< .button type = "submit" > Save< / .button >
< .inline_feedback status = {@save_status} / >
2026-03-05 15:30:29 +00:00
```
2026-03-08 07:55:55 +00:00
**States:**
- `:idle` — hidden
- `:saving` — spinner + "Saving..."
- `:saved` — green tick + "Saved"
- `:error` — red icon + "Something went wrong" (or custom message)
2026-03-05 15:30:29 +00:00
2026-03-08 07:55:55 +00:00
**Implementation pattern:**
2026-03-05 15:30:29 +00:00
```elixir
# Mount
2026-03-08 07:55:55 +00:00
assign(socket, :save_status, :idle)
2026-03-05 15:30:29 +00:00
2026-03-08 07:55:55 +00:00
# Validate event — clear status on form change
def handle_event("validate", params, socket) do
{:noreply, assign(socket, save_status: :idle)}
end
# Save event
2026-03-05 15:30:29 +00:00
def handle_event("save", params, socket) do
case Context.save(params) do
2026-03-08 07:55:55 +00:00
{:ok, _} -> {:noreply, assign(socket, :save_status, :saved)}
{:error, _} -> {:noreply, assign(socket, :save_status, :error)}
2026-03-05 15:30:29 +00:00
end
end
```
2026-03-08 07:55:55 +00:00
**When to use:** Theme editor, page editor, settings forms, media metadata, product details, navigation editor, newsletter drafts.
2026-03-05 15:30:29 +00:00
2026-03-08 07:55:55 +00:00
### `<.flash>` / `<.shop_flash_group>` — page-level banners
2026-03-05 15:30:29 +00:00
2026-03-08 07:55:55 +00:00
Used for outcomes that affect the whole page or result from navigation. Renders in document flow (pushes content down), not fixed overlay.
2026-03-05 15:30:29 +00:00
2026-03-08 07:55:55 +00:00
```elixir
socket
|> put_flash(:info, "Product sync started")
|> push_navigate(to: ~p"/admin/products")
2026-03-05 15:30:29 +00:00
```
2026-03-08 07:55:55 +00:00
**Behaviour:**
- Persistent until dismissed (close button)
- Clears automatically on next navigation
- Info messages use `role="status"` + `aria-live="polite"`
- Error messages use `role="alert"` + `aria-live="assertive"`
2026-03-03 22:45:41 +00:00
2026-03-08 07:55:55 +00:00
**When to use:**
- Operation outcomes: "Sync started", "Page deleted", "Campaign sent"
- Auth messages: "Welcome back!", "Logged out"
- Connection/provider status changes
- Any action that navigates to a new page
2026-03-03 22:45:41 +00:00
2026-03-08 07:55:55 +00:00
## Decision tree
2026-03-03 22:45:41 +00:00
2026-03-08 07:55:55 +00:00
```
Is this feedback for a form save that stays on the page?
├─ YES → Use inline_feedback
│ - Add :save_status assign
│ - Clear to :idle on validate
│ - Set to :saved/:error on submit
│
└─ NO → Does the action navigate away or affect the whole page?
├─ YES → Use put_flash (banner)
│ - :info for success
│ - :error for failures
│
└─ NO → Use inline_feedback anyway
(contextual is usually better)
```
2026-03-03 22:45:41 +00:00
2026-03-08 07:55:55 +00:00
## Progressive enhancement
2026-03-03 22:45:41 +00:00
2026-03-08 07:55:55 +00:00
Forms should work without JavaScript. The pattern:
2026-03-03 22:45:41 +00:00
2026-03-08 07:55:55 +00:00
1. Form has both `action` (controller POST) and `phx-submit` (LiveView)
2. With JS: LiveView handles the event, shows inline feedback
3. Without JS: Form POSTs to controller, redirects with flash
2026-03-03 22:45:41 +00:00
2026-03-08 07:55:55 +00:00
```heex
< .form
for={@form}
action={~p"/admin/settings/email"}
method="post"
phx-change="validate"
phx-submit="save"
>
```
2026-03-03 22:45:41 +00:00
2026-03-08 07:55:55 +00:00
The controller sets a flash and redirects:
2026-03-03 22:45:41 +00:00
2026-03-08 07:55:55 +00:00
```elixir
def update(conn, params) do
case Context.save(params) do
{:ok, _} ->
conn
|> put_flash(:info, "Settings saved")
|> redirect(to: ~p"/admin/settings")
{:error, changeset} ->
render(conn, :edit, form: to_form(changeset))
end
end
```
2026-03-03 22:45:41 +00:00
2026-03-08 07:55:55 +00:00
**Result:** With JS, user sees instant inline feedback. Without JS, user gets a banner after redirect.
2026-03-03 22:45:41 +00:00
2026-03-08 07:55:55 +00:00
## Accessibility requirements
2026-03-03 22:45:41 +00:00
2026-03-08 07:55:55 +00:00
All notification components must include:
2026-03-05 15:30:29 +00:00
2026-03-08 07:55:55 +00:00
1. **Appropriate role:**
- `role="status"` for info/success (non-urgent)
- `role="alert"` for errors (urgent)
2026-03-03 22:45:41 +00:00
2026-03-08 07:55:55 +00:00
2. **aria-live attribute:**
- `aria-live="polite"` for info/success
- `aria-live="assertive"` for errors
2026-03-03 22:45:41 +00:00
2026-03-08 07:55:55 +00:00
3. **Dismiss buttons:**
- Must be actual `<button>` elements
- Include `aria-label="Close"`
- Icon must have `aria-hidden="true"`
2026-03-03 22:45:41 +00:00
2026-03-08 07:55:55 +00:00
## Files
2026-03-03 22:45:41 +00:00
2026-03-08 07:55:55 +00:00
- **Component:** `lib/berrypod_web/components/core_components.ex` — `inline_feedback/1` , `flash/1`
- **Shop flash:** `lib/berrypod_web/components/shop_components/content.ex` — `shop_flash_group/1`
- **Admin CSS:** `assets/css/admin/core.css` — `.admin-inline-feedback` , `.admin-banner` , `.admin-alert`
- **Shop CSS:** `assets/css/shop/components.css` — `.shop-flash`
2026-03-03 22:45:41 +00:00
2026-03-08 07:55:55 +00:00
## Examples in codebase
2026-03-03 22:45:41 +00:00
2026-03-08 07:55:55 +00:00
| Page | Feedback type | File |
|------|--------------|------|
| Theme editor | inline_feedback | `lib/berrypod_web/live/admin/theme/index.ex` |
| Page editor | inline_feedback | `lib/berrypod_web/live/admin/pages/editor.ex` |
| Settings | inline_feedback | `lib/berrypod_web/live/admin/settings.ex` |
| Email settings | inline_feedback | `lib/berrypod_web/live/admin/email_settings.ex` |
| Media library | inline_feedback | `lib/berrypod_web/live/admin/media.ex` |
| Product show | inline_feedback | `lib/berrypod_web/live/admin/product_show.ex` |
| Navigation | inline_feedback | `lib/berrypod_web/live/admin/navigation.ex` |
| Newsletter | inline_feedback | `lib/berrypod_web/live/admin/newsletter/campaign_form.ex` |
| Provider form | flash (navigates) | `lib/berrypod_web/live/admin/providers/form.ex` |
| Contact | flash (navigates) | `lib/berrypod_web/live/shop/contact.ex` |
2026-03-05 15:30:29 +00:00
2026-03-08 07:55:55 +00:00
## Common mistakes
2026-03-03 22:45:41 +00:00
2026-03-08 07:55:55 +00:00
**Wrong:** Using `put_flash` for a form save that stays on the page
```elixir
# Don't do this
def handle_event("save", params, socket) do
{:ok, _} = Context.save(params)
{:noreply, put_flash(socket, :info, "Saved")}
end
```
2026-03-03 22:45:41 +00:00
2026-03-08 07:55:55 +00:00
**Right:** Using inline feedback
```elixir
def handle_event("save", params, socket) do
{:ok, _} = Context.save(params)
{:noreply, assign(socket, :save_status, :saved)}
end
```
2026-03-03 22:45:41 +00:00
2026-03-08 07:55:55 +00:00
**Wrong:** Forgetting to clear status on validate
```elixir
# Status stays "Saved" even while user is editing
def handle_event("validate", params, socket) do
{:noreply, assign(socket, :form, to_form(params))}
end
```
2026-03-03 22:45:41 +00:00
2026-03-08 07:55:55 +00:00
**Right:** Clear status when form changes
```elixir
def handle_event("validate", params, socket) do
{:noreply, assign(socket, form: to_form(params), save_status: :idle)}
end
```
2026-03-03 22:45:41 +00:00
2026-03-08 07:55:55 +00:00
**Wrong:** Making flash the whole-div clickable
```heex
< div phx-click = {dismiss()} >
< p > {msg}< / p >
< button > × < / button > <!-- button does nothing -->
< / div >
```
2026-03-05 15:30:29 +00:00
2026-03-08 07:55:55 +00:00
**Right:** Click handler on the close button
```heex
< div >
< p > {msg}< / p >
< button phx-click = {dismiss()} aria-label = "Close" > × < / button >
< / div >
```