diff --git a/messages/de.json b/messages/de.json index ddfde528c..6f9e4d9a6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -677,7 +677,12 @@ "rationaleDeviation": "Abweichung", "rationaleExpand": "Begründung anzeigen", "rationaleCollapse": "Begründung ausblenden", - "legacyPayloadCta": "Insights aktualisiert — Neugenerierung lädt die neuen Erklärfunktionen" + "legacyPayloadCta": "Insights aktualisiert — Neugenerierung lädt die neuen Erklärfunktionen", + "feedbackHelpful": "Hilfreich", + "feedbackNotHelpful": "Nicht hilfreich", + "feedbackThanks": "Danke für dein Feedback", + "feedbackConfirmed": "Feedback gespeichert", + "feedbackAlreadyRated": "Bereits bewertet" }, "dataFoundation": "Datengrundlage", "dataGaps": "Datenlücken:", diff --git a/messages/en.json b/messages/en.json index 05c547768..ec4dfc294 100644 --- a/messages/en.json +++ b/messages/en.json @@ -677,7 +677,12 @@ "rationaleDeviation": "Deviation", "rationaleExpand": "Show why", "rationaleCollapse": "Hide why", - "legacyPayloadCta": "Insights updated — regenerate for new explainability features" + "legacyPayloadCta": "Insights updated — regenerate for new explainability features", + "feedbackHelpful": "Helpful", + "feedbackNotHelpful": "Not helpful", + "feedbackThanks": "Thanks for your feedback", + "feedbackConfirmed": "Feedback recorded", + "feedbackAlreadyRated": "Already rated" }, "dataFoundation": "Data coverage", "dataGaps": "Data gaps:", diff --git a/src/components/insights/__tests__/recommendation-feedback.test.tsx b/src/components/insights/__tests__/recommendation-feedback.test.tsx new file mode 100644 index 000000000..5c00d580a --- /dev/null +++ b/src/components/insights/__tests__/recommendation-feedback.test.tsx @@ -0,0 +1,93 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { I18nProvider } from "@/lib/i18n/context"; +import { RecommendationFeedback } from "../recommendation-feedback"; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: null, isLoading: false }), + useMutation: () => ({ + mutate: vi.fn(), + isPending: false, + isSuccess: false, + isError: false, + error: null, + }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "test-user", username: "tester", role: "USER" }, + isAuthenticated: true, + isLoading: false, + }), +})); + +const baseProps = { + recId: "rec-1", + recText: "Discuss home BP log with your physician.", + recSeverity: "important" as const, + metricSourceType: "bloodPressure", + metricSourceTimeRange: "last7days" as const, +}; + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +describe("", () => { + it("renders thumbs-up + thumbs-down buttons", () => { + const html = render(); + expect(html).toContain("data-feedback-thumb=\"up\""); + expect(html).toContain("data-feedback-thumb=\"down\""); + }); + + it("attaches accessible aria-label keys", () => { + const html = render(); + expect(html).toContain("aria-label=\"Helpful"); + expect(html).toContain("aria-label=\"Not helpful"); + }); + + it("translates the labels to German when locale=de", () => { + const html = render(, "de"); + expect(html).toContain("Hilfreich"); + expect(html).toContain("Nicht hilfreich"); + }); + + it("renders the 'thanks for your feedback' confirmation when initialState=submitted-up", () => { + const html = render( + , + ); + // Confirmation text replaces both buttons. + expect(html).toContain("Thanks"); + // Highlighted thumb-up still rendered for context. + expect(html).toContain("data-feedback-confirmed=\"up\""); + // The opposite button should NOT be re-clickable. + expect(html).not.toContain('data-feedback-thumb="down"'); + }); + + it("renders the 'already rated' state with the prior verdict highlighted", () => { + const html = render( + , + ); + expect(html).toContain("data-feedback-already-rated=\"down\""); + // Buttons are not interactive in this state. + expect(html).not.toContain('data-feedback-thumb="up"'); + expect(html).not.toContain('data-feedback-thumb="down"'); + }); + + it("buttons carry type=button so they don't accidentally submit a parent form", () => { + const html = render(); + // Both up + down buttons must declare type=button. + const upMatch = html.match( + /]*data-feedback-thumb="up"[^>]*>/, + ); + const downMatch = html.match( + /]*data-feedback-thumb="down"[^>]*>/, + ); + expect(upMatch?.[0]).toContain("type=\"button\""); + expect(downMatch?.[0]).toContain("type=\"button\""); + }); +}); diff --git a/src/components/insights/recommendation-feedback.tsx b/src/components/insights/recommendation-feedback.tsx new file mode 100644 index 000000000..438eedeb4 --- /dev/null +++ b/src/components/insights/recommendation-feedback.tsx @@ -0,0 +1,293 @@ +"use client"; + +/** + * v1.4.16 phase B5e — RecommendationFeedback. + * + * Two small icon buttons (thumb-up / thumb-down) under each rec card, + * surfaced in the `data-slot="rec-feedback-slot"` slot reserved by + * B5c's ``. + * + * UX states: + * - default → both buttons rendered, neither pressed + * - submitting → buttons disabled, the chosen verdict spinner-tinted + * - submitted-up → confirmation row replaces the buttons + the + * pressed thumb is highlighted; "Thanks for your + * feedback" text + * - submitted-down → mirror of submitted-up for the negative verdict + * - already-rated-up / -down → quiet state when the server returned + * 409 (the user already rated this rec text in a previous session) + * + * Local cache: a `(userId, recId, recText)` tuple is keyed in + * localStorage so a refresh doesn't allow re-rating. Server-side dedup + * via the unique constraint on `recommendation_feedback` is the source + * of truth — the local cache is just a UX nicety. + * + * Keyboard accessible: both buttons are real ` + + + ); +}