defmodule PhiaUi.Components.FeedbackWidget do @moduledoc """ Micro-feedback collection components for gathering quick user feedback without interrupting the user's workflow. Inspired by Notion's page reactions, GitHub's "Was this helpful?" pattern, Intercom's CSAT survey, and Linear's NPS widget. Pure HEEx — no JavaScript hooks required. All state managed server-side via LiveView assigns and phx-click events. ## Sub-components | Component | Purpose | |------------------------|--------------------------------------------------------------| | `feedback_thumb/1` | Single thumbs-up "Was this helpful?" button | | `feedback_reaction/1` | Emoji/icon reaction bar (👍 ❤️ 😂 😮 😢 😡) | | `feedback_nps/1` | Net Promoter Score 0–10 scale widget | | `feedback_survey_card/1`| "Was this helpful?" card with Yes/No and optional comment | ## Thumbs up (docs helpful) <.feedback_thumb helpful={@page_helpful} count={@helpful_count} phx-click="toggle_helpful" /> ## Emoji reactions <.feedback_reaction reactions={@reactions} current_reaction={@my_reaction} phx-value-reaction="..." phx-click="react" /> ## NPS score <.feedback_nps selected={@nps_score} phx-click="submit_nps" phx-value-score="..." /> ## Survey card <.feedback_survey_card :if={!@feedback_submitted} title="Was this article helpful?" phx-click-yes="feedback_yes" phx-click-no="feedback_no" /> """ use Phoenix.Component import PhiaUi.ClassMerger, only: [cn: 1] import PhiaUi.Components.Icon, only: [icon: 1] # --------------------------------------------------------------------------- # feedback_thumb/1 # --------------------------------------------------------------------------- attr(:helpful, :boolean, default: false, doc: "Whether the current user has marked as helpful." ) attr(:count, :integer, default: nil, doc: "Optional count of people who found it helpful." ) attr(:label, :string, default: "Helpful", doc: "Button label text." ) attr(:class, :string, default: nil, doc: "Additional CSS classes.") attr(:rest, :global, doc: "HTML attrs forwarded to the ` """ end # --------------------------------------------------------------------------- # feedback_reaction/1 # --------------------------------------------------------------------------- attr(:reactions, :list, default: [], doc: """ List of reaction maps. Each map: - `:emoji` (string) — the emoji character or label - `:count` (integer) — current count - `:value` (string) — value sent as phx-value-reaction Example: `[%{emoji: "👍", count: 12, value: "like"}]` """ ) attr(:current_reaction, :string, default: nil, doc: "The `:value` of the reaction the current user has selected (if any)." ) attr(:class, :string, default: nil, doc: "Additional CSS classes.") attr(:rest, :global, doc: "HTML attrs forwarded to the wrapper `
`. Add `phx-click` here.") @doc """ Renders an emoji reaction bar. Each reaction button shows the emoji + count. The active reaction (matching `:current_reaction`) is highlighted. Wire `phx-click` and `phx-value-reaction` at the wrapper level for event delegation. ## Example <.feedback_reaction reactions={[ %{emoji: "👍", count: 14, value: "like"}, %{emoji: "❤️", count: 7, value: "love"}, %{emoji: "😂", count: 3, value: "laugh"}, %{emoji: "😮", count: 2, value: "wow"} ]} current_reaction={@my_reaction} phx-click="react" /> """ def feedback_reaction(assigns) do ~H"""
""" end # --------------------------------------------------------------------------- # feedback_nps/1 # --------------------------------------------------------------------------- attr(:selected, :any, default: nil, doc: "Currently selected score (0–10), or `nil` if not yet answered." ) attr(:question, :string, default: "How likely are you to recommend us to a friend?", doc: "The NPS question displayed above the scale." ) attr(:class, :string, default: nil, doc: "Additional CSS classes.") attr(:rest, :global, doc: "HTML attrs forwarded to the wrapper. Add `phx-click` here.") @doc """ Renders a Net Promoter Score (NPS) 0–10 rating widget. Wire `phx-click` and `phx-value-score` at the wrapper level or on each button. Scores 0–6 are Detractors (red), 7–8 Passives (amber), 9–10 Promoters (green). ## Example <.feedback_nps question="How likely are you to recommend PhiaUI?" selected={@nps_score} phx-click="submit_nps" /> # LiveView handler def handle_event("submit_nps", %{"score" => score}, socket) do {:noreply, assign(socket, nps_score: String.to_integer(score))} end """ def feedback_nps(assigns) do ~H"""

{@question}

Not likely Extremely likely
""" end # --------------------------------------------------------------------------- # feedback_survey_card/1 # --------------------------------------------------------------------------- attr(:title, :string, default: "Was this helpful?", doc: "Survey question displayed as the card title." ) attr(:description, :string, default: nil, doc: "Optional sub-text below the title." ) attr(:yes_label, :string, default: "Yes", doc: "Label for the positive response.") attr(:no_label, :string, default: "No", doc: "Label for the negative response.") attr(:class, :string, default: nil, doc: "Additional CSS classes.") attr(:rest, :global, doc: "HTML attrs forwarded to the card `
`.") slot(:after_answer, doc: "Content to render after the user answers (e.g. thank-you message). Wire using `:if`." ) @doc """ Renders a compact "Was this helpful?" survey card. Provides Yes/No buttons with `phx-click-yes` and `phx-click-no` support. Combine with a LiveView assign to swap the card content after answering. ## Example <.feedback_survey_card :if={!@feedback_submitted} title="Was this article helpful?" phx-click-yes="feedback_yes" phx-click-no="feedback_no" />
Thank you for your feedback!
""" def feedback_survey_card(assigns) do yes_event = Map.get(assigns.rest, :"phx-click-yes") || Map.get(assigns.rest, :phx_click_yes) no_event = Map.get(assigns.rest, :"phx-click-no") || Map.get(assigns.rest, :phx_click_no) rest = assigns.rest |> Map.drop([:"phx-click-yes", :"phx-click-no", :phx_click_yes, :phx_click_no]) assigns = assign(assigns, yes_event: yes_event, no_event: no_event, clean_rest: rest) ~H"""

{@title}

{@description}

{render_slot(@after_answer)}
""" end # --------------------------------------------------------------------------- # Private helpers # --------------------------------------------------------------------------- # NPS color: 0-6 = detractor (red), 7-8 = passive (amber), 9-10 = promoter (green) defp nps_selected_class(score) when score <= 6, do: "bg-destructive/10 border-destructive/40 text-destructive font-semibold" defp nps_selected_class(score) when score <= 8, do: "bg-warning/10 border-warning/40 text-warning font-semibold" defp nps_selected_class(_score), do: "bg-success/10 border-success/40 text-success font-semibold" end