From 3243282bfd22f5dda8e04175f18bb26f4b2426d4 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Tue, 28 Apr 2026 01:25:07 +0200 Subject: [PATCH] feat(ST-1105): add NavBar bell + sheet + answer-modal + Zustand store + SSE hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI-volledig voor de Claude vraag-antwoord-flow (M11). Bel-icon links van avatar in NavBar; klik opent slide-over rechts met openstaande vragen; klik op een vraag opent een modal voor antwoord. Story-assignee = current user krijgt visuele "voor jou"-emphase met primary-container accent en error-color badge-ring. Bestanden: - stores/notifications-store.ts — Zustand store met init/upsert/remove + openCount/forYouCount selectors (vereenvoudigd vs solo-store: geen pendingOps, geen optimistic-echo-onderdrukking) - lib/realtime/use-notifications-realtime.ts — EventSource hook met state- event en message-event handling, exponential-backoff reconnect, Page Visibility pause-resume - components/notifications/notifications-bridge.tsx — Server Component die initial open-questions fetcht via productAccessFilter - components/notifications/notifications-realtime-mount.tsx — tiny client island dat de store hydrateert + de hook activeert - components/notifications/notifications-sheet.tsx — shadcn Sheet met item- lijst, "voor jou"-accent voor assignee-vragen, lege staat - components/notifications/answer-modal.tsx — Dialog met options-radio of free-text Textarea (max 4000), char-counter, demo-blok via Tooltip; bij succes optimistisch remove + sheet blijft open zodat meerdere vragen achter elkaar te beantwoorden zijn - components/shared/notifications-bell.tsx — Bell-icon met badge (count >9 → "9+"), ring-accent als forYouCount > 0, ARIA-label voor screenreaders Wiring: - components/shared/nav-bar.tsx — rechts naast - app/(app)/layout.tsx — naast , user.id (server-side) als prop base-ui-aanpassingen: SheetTrigger/TooltipTrigger gebruiken render-prop ipv asChild (geen Radix). Quality gates: lint 0 errors, tsc clean, vitest 146/146, npm run build groen. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/(app)/layout.tsx | 2 + components/notifications/answer-modal.tsx | 157 ++++++++++++++++++ .../notifications/notifications-bridge.tsx | 62 +++++++ .../notifications-realtime-mount.tsx | 23 +++ .../notifications/notifications-sheet.tsx | 106 ++++++++++++ components/shared/nav-bar.tsx | 4 +- components/shared/notifications-bell.tsx | 54 ++++++ lib/realtime/use-notifications-realtime.ts | 126 ++++++++++++++ stores/notifications-store.ts | 61 +++++++ 9 files changed, 594 insertions(+), 1 deletion(-) create mode 100644 components/notifications/answer-modal.tsx create mode 100644 components/notifications/notifications-bridge.tsx create mode 100644 components/notifications/notifications-realtime-mount.tsx create mode 100644 components/notifications/notifications-sheet.tsx create mode 100644 components/shared/notifications-bell.tsx create mode 100644 lib/realtime/use-notifications-realtime.ts create mode 100644 stores/notifications-store.ts diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 1a5b3b9..fa41d4a 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -9,6 +9,7 @@ import { NavBar } from '@/components/shared/nav-bar' import { MinWidthBanner } from '@/components/shared/min-width-banner' import { StatusBar } from '@/components/shared/status-bar' import { SoloRealtimeBridge } from '@/components/solo/realtime-bridge' +import { NotificationsBridge } from '@/components/notifications/notifications-bridge' import { AlertToast } from '@/components/shared/alert-toast' import { Suspense } from 'react' @@ -92,6 +93,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod + diff --git a/components/notifications/answer-modal.tsx b/components/notifications/answer-modal.tsx new file mode 100644 index 0000000..cbe574d --- /dev/null +++ b/components/notifications/answer-modal.tsx @@ -0,0 +1,157 @@ +'use client' + +// ST-1105: Modal waar de gebruiker een Claude-vraag beantwoordt (M11). +// +// Free-text Textarea (max 4000) of multiple-choice via knoppen wanneer de +// vraag `options` heeft. Submit roept answerQuestion-Server-Action aan via +// useTransition; bij succes wordt de vraag uit de store verwijderd +// (optimistisch) en sluit de modal. Demo-modus: textarea readOnly + submit +// disabled met tooltip. + +import { useState, useTransition } from 'react' +import Link from 'next/link' +import { ExternalLink } from 'lucide-react' +import { toast } from 'sonner' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Textarea } from '@/components/ui/textarea' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { answerQuestion } from '@/actions/questions' +import { useNotificationsStore, type NotificationQuestion } from '@/stores/notifications-store' + +const MAX_ANSWER_CHARS = 4000 + +interface AnswerModalProps { + question: NotificationQuestion | null + isDemo: boolean + onClose: () => void +} + +export function AnswerModal({ question, isDemo, onClose }: AnswerModalProps) { + const [answer, setAnswer] = useState('') + const [pending, startTransition] = useTransition() + + if (!question) return null + + const charsLeft = MAX_ANSWER_CHARS - answer.length + const tooLong = charsLeft < 0 + const submitDisabled = isDemo || pending || answer.trim().length === 0 || tooLong + + function submit(text: string) { + if (!question) return + if (isDemo) { + toast.error('Niet beschikbaar in demo-modus') + return + } + startTransition(async () => { + const res = await answerQuestion(question.id, text) + if (!res.ok) { + toast.error(res.error) + return + } + // Optimistisch verwijderen — SSE-event komt anders later met dezelfde + // remove en kost een extra render + useNotificationsStore.getState().remove(question.id) + toast.success('Antwoord verstuurd') + setAnswer('') + onClose() + }) + } + + return ( + !open && onClose()}> + + + Beantwoord Claude + + {question.story_code ?? 'story'} + {' — '} + {question.story_title} + + + + + Open in Sprint + + +
+ {question.question} +
+ + {question.options && question.options.length > 0 ? ( +
+

Kies een van de opties:

+
+ {question.options.map((opt) => ( + + ))} +
+
+ ) : ( +
+