From d67405df5f27a6e05c1106540f818afe8a40ea64 Mon Sep 17 00:00:00 2001 From: colinislit Date: Wed, 24 Dec 2025 09:28:10 +0100 Subject: [PATCH] feat(swift): Epic 4 - Navigation & Auth compleet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E4.S1 - Login form uitbreiden: - Interface selector (Swift/Klassiek) toegevoegd - Visuele keuze met icons (Layout/Zap) - Redirect naar gekozen interface na login E4.S2 - Preference opslag: - updateInterfacePreference() in lib/auth/client.ts - getInterfacePreference() voor ophalen - Opslag in user_metadata.preferred_interface E4.S3 - Redirect middleware: - /epd β†’ redirect naar preferred interface - /login β†’ redirect naar preferred interface (als ingelogd) - Default: klassiek (/epd/clients) E4.S4 - Fallback Picker: - FallbackPicker component voor lage confidence - Grid met 3 opties (Notitie, Zoeken, Overdracht) - Keyboard shortcuts [1], [2], [3] - Toont originele input voor context Voortgang: Epic 4 compleet (8 SP) πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app/login/components/login-form.tsx | 12 +- components/swift/blocks/fallback-picker.tsx | 165 ++++++++++++++++++ components/swift/blocks/index.ts | 1 + .../swift/command-center/canvas-area.tsx | 6 +- .../swift/command-center/command-input.tsx | 23 +-- docs/swift/bouwplan-swift-v2.md | 10 +- lib/auth/client.ts | 30 ++++ middleware.ts | 15 +- stores/swift-store.ts | 2 +- 9 files changed, 233 insertions(+), 31 deletions(-) create mode 100644 components/swift/blocks/fallback-picker.tsx diff --git a/app/login/components/login-form.tsx b/app/login/components/login-form.tsx index 213cc60..cb25681 100644 --- a/app/login/components/login-form.tsx +++ b/app/login/components/login-form.tsx @@ -4,9 +4,7 @@ import { useState } from 'react' import { useRouter } from 'next/navigation' import Link from 'next/link' import { Eye, EyeOff, Zap, Layout } from 'lucide-react' -import { loginWithPassword, signUpWithPassword } from '@/lib/auth/client' - -type InterfacePreference = 'swift' | 'klassiek' +import { loginWithPassword, signUpWithPassword, updateInterfacePreference, type InterfacePreference } from '@/lib/auth/client' export function LoginForm() { const router = useRouter() @@ -38,6 +36,8 @@ export function LoginForm() { const result = await signUpWithPassword(email, password) if (result.session) { + // Save interface preference after successful signup + await updateInterfacePreference(interfacePreference) setMessage({ type: 'success', text: 'Account aangemaakt! Je wordt doorgestuurd...' }) setTimeout(() => { router.push(getRedirectPath()) @@ -50,6 +50,8 @@ export function LoginForm() { } } else { await loginWithPassword(email, password) + // Save interface preference after successful login + await updateInterfacePreference(interfacePreference) setMessage({ type: 'success', text: 'Ingelogd! Je wordt doorgestuurd...' }) setTimeout(() => { router.push(getRedirectPath()) @@ -57,6 +59,8 @@ export function LoginForm() { } } catch (error: any) { if (error.code === 'user_already_registered' && error.data?.session) { + // Save interface preference for auto-logged in user + await updateInterfacePreference(interfacePreference) setMessage({ type: 'success', text: 'Dit emailadres bestaat al. Je bent nu ingelogd!' }) setTimeout(() => { router.push(getRedirectPath()) @@ -90,6 +94,8 @@ export function LoginForm() { setLoading(true) try { await loginWithPassword('demo@mini-ecd.demo', 'Demo2024!') + // Save interface preference for demo user + await updateInterfacePreference(interfacePreference) router.push(getRedirectPath()) } catch (error: any) { setMessage({ type: 'error', text: 'Demo login mislukt. Probeer handmatig in te loggen.' }) diff --git a/components/swift/blocks/fallback-picker.tsx b/components/swift/blocks/fallback-picker.tsx new file mode 100644 index 0000000..32097b4 --- /dev/null +++ b/components/swift/blocks/fallback-picker.tsx @@ -0,0 +1,165 @@ +'use client'; + +/** + * Fallback Picker + * + * Visual intent selector shown when classification confidence is low. + * E4.S4: Grid met block opties, keyboard shortcuts (1-3). + */ + +import { useEffect, useCallback } from 'react'; +import { motion } from 'framer-motion'; +import { FileText, Search, ArrowRightLeft, X } from 'lucide-react'; +import { useSwiftStore } from '@/stores/swift-store'; +import type { BlockType } from '@/lib/swift/types'; + +interface FallbackPickerProps { + originalInput?: string; +} + +interface BlockOption { + type: BlockType; + label: string; + description: string; + icon: typeof FileText; + shortcut: string; + color: string; +} + +const BLOCK_OPTIONS: BlockOption[] = [ + { + type: 'dagnotitie', + label: 'Notitie', + description: 'Schrijf een dagnotitie', + icon: FileText, + shortcut: '1', + color: 'bg-blue-500/20 text-blue-400 border-blue-500/30', + }, + { + type: 'zoeken', + label: 'Zoeken', + description: 'Zoek een patiΓ«nt', + icon: Search, + shortcut: '2', + color: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30', + }, + { + type: 'overdracht', + label: 'Overdracht', + description: 'Bekijk overdracht', + icon: ArrowRightLeft, + shortcut: '3', + color: 'bg-amber-500/20 text-amber-400 border-amber-500/30', + }, +]; + +export function FallbackPicker({ originalInput }: FallbackPickerProps) { + const { openBlock, closeBlock, addRecentAction } = useSwiftStore(); + + const handleSelect = useCallback( + (option: BlockOption) => { + // Pass original input as content for dagnotitie, or as search query for zoeken + const prefillData = + option.type === 'dagnotitie' + ? { content: originalInput } + : option.type === 'zoeken' + ? { patientName: originalInput } + : {}; + + openBlock(option.type, prefillData); + + addRecentAction({ + intent: option.type, + label: option.label, + }); + }, + [openBlock, addRecentAction, originalInput] + ); + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Don't handle if in input field + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return; + } + + // Number keys 1-3 for quick select + const keyNum = parseInt(e.key); + if (keyNum >= 1 && keyNum <= BLOCK_OPTIONS.length) { + e.preventDefault(); + handleSelect(BLOCK_OPTIONS[keyNum - 1]); + } + + // Escape to close + if (e.key === 'Escape') { + e.preventDefault(); + closeBlock(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [handleSelect, closeBlock]); + + return ( + + {/* Header */} +
+

Wat wil je doen?

+ +
+ + {/* Original input display */} + {originalInput && ( +
+

+ Je zei: "{originalInput}" +

+
+ )} + + {/* Options grid */} +
+
+ {BLOCK_OPTIONS.map((option) => ( + handleSelect(option)} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + className={`flex flex-col items-center gap-2 p-4 rounded-lg border transition-all ${option.color} hover:brightness-110`} + > + + {option.label} + {option.description} + + [{option.shortcut}] + + + ))} +
+
+ + {/* Footer hint */} +
+

+ Druk op [1], [2] of [3] voor snelle selectie +

+
+
+ ); +} diff --git a/components/swift/blocks/index.ts b/components/swift/blocks/index.ts index 0ab8420..241a9f7 100644 --- a/components/swift/blocks/index.ts +++ b/components/swift/blocks/index.ts @@ -7,3 +7,4 @@ export { DagnotatieBlock } from './dagnotitie-block'; export { ZoekenBlock } from './zoeken-block'; export { OverdrachtBlock } from './overdracht-block'; export { PatientContextCard } from './patient-context-card'; +export { FallbackPicker } from './fallback-picker'; diff --git a/components/swift/command-center/canvas-area.tsx b/components/swift/command-center/canvas-area.tsx index 1bd6248..bfbba22 100644 --- a/components/swift/command-center/canvas-area.tsx +++ b/components/swift/command-center/canvas-area.tsx @@ -8,12 +8,12 @@ import { AnimatePresence, motion } from 'framer-motion'; import { useSwiftStore } from '@/stores/swift-store'; -import type { BlockType } from '@/lib/swift/types'; -import type { BlockPrefillData } from '@/stores/swift-store'; +import type { BlockType, BlockPrefillData } from '@/stores/swift-store'; import { DagnotatieBlock } from '../blocks/dagnotitie-block'; import { ZoekenBlock } from '../blocks/zoeken-block'; import { OverdrachtBlock } from '../blocks/overdracht-block'; import { PatientContextCard } from '../blocks/patient-context-card'; +import { FallbackPicker } from '../blocks/fallback-picker'; export function CanvasArea() { const { activeBlock, prefillData, activePatient } = useSwiftStore(); @@ -26,6 +26,8 @@ export function CanvasArea() { return ; case 'overdracht': return ; + case 'fallback': + return ; default: return null; } diff --git a/components/swift/command-center/command-input.tsx b/components/swift/command-center/command-input.tsx index 3f80eb4..9edce3c 100644 --- a/components/swift/command-center/command-input.tsx +++ b/components/swift/command-center/command-input.tsx @@ -152,29 +152,16 @@ export const CommandInput = forwardRef(function CommandInput(_ clearInput(); } else { - // Low confidence or unknown intent - temporary fallback to dagnotitie - // TODO: Replace with FallbackPicker in E4.S4 - openBlock('dagnotitie', { content: inputText }); - - addRecentAction({ - intent: 'dagnotitie', - label: inputText.slice(0, 50), - }); - + // Low confidence or unknown intent - show FallbackPicker + openBlock('fallback', { content: inputText }); clearInput(); } } catch (error) { console.error('Error processing intent:', error); - - // On error, fallback to dagnotitie with the input as content + + // On error, show FallbackPicker so user can choose // This ensures the user's input is not lost - openBlock('dagnotitie', { content: inputText }); - - addRecentAction({ - intent: 'dagnotitie', - label: inputText.slice(0, 50), - }); - + openBlock('fallback', { content: inputText }); clearInput(); } finally { setIsProcessing(false); diff --git a/docs/swift/bouwplan-swift-v2.md b/docs/swift/bouwplan-swift-v2.md index c2824fb..b1005e6 100644 --- a/docs/swift/bouwplan-swift-v2.md +++ b/docs/swift/bouwplan-swift-v2.md @@ -346,15 +346,15 @@ function renderBlock(activeBlock: BlockType, prefillData: BlockPrefillData) { --- -### Epic 4 β€” Navigation & Auth ⏳ TO DO +### Epic 4 β€” Navigation & Auth βœ… DONE **Epic Doel:** Login pagina met interface keuze, routing naar Swift/Klassiek. | Story ID | Beschrijving | Acceptatiecriteria | Status | Afh. | SP | |----------|--------------|---------------------|--------|------|----| -| E4.S1 | Login form uitbreiden | Interface selector (Swift/Klassiek) | ⏳ | E0.S3 | 2 | -| E4.S2 | Preference opslag | user_metadata.preferred_interface | ⏳ | E4.S1 | 2 | -| E4.S3 | Redirect middleware | /epd β†’ preference route | ⏳ | E4.S2 | 2 | -| E4.S4 | Fallback Picker | Visuele keuze bij lage confidence | ⏳ | E3.S0 | 2 | +| E4.S1 | Login form uitbreiden | Interface selector (Swift/Klassiek) | βœ… | E0.S3 | 2 | +| E4.S2 | Preference opslag | user_metadata.preferred_interface | βœ… | E4.S1 | 2 | +| E4.S3 | Redirect middleware | /epd β†’ preference route | βœ… | E4.S2 | 2 | +| E4.S4 | Fallback Picker | Visuele keuze bij lage confidence | βœ… | E3.S0 | 2 | --- diff --git a/lib/auth/client.ts b/lib/auth/client.ts index 8197a52..96ecfdd 100644 --- a/lib/auth/client.ts +++ b/lib/auth/client.ts @@ -268,3 +268,33 @@ export async function isDemoUser(): Promise { export async function getDemoAccessLevel(): Promise<'read_only' | 'interactive' | 'presenter' | null> { return null } + +/** + * Update user's preferred interface (Swift or Klassiek) + * Stored in user_metadata.preferred_interface + */ +export type InterfacePreference = 'swift' | 'klassiek' + +export async function updateInterfacePreference(preference: InterfacePreference) { + const supabase = createClient() + const { error } = await supabase.auth.updateUser({ + data: { preferred_interface: preference } + }) + + if (error) { + console.error('Failed to update interface preference:', error) + throw error + } + + return true +} + +/** + * Get user's preferred interface from metadata + * Returns 'klassiek' as default if not set + */ +export async function getInterfacePreference(): Promise { + const user = await getUser() + const preference = user?.user_metadata?.preferred_interface as InterfacePreference | undefined + return preference || 'klassiek' +} diff --git a/middleware.ts b/middleware.ts index 7626906..8160396 100644 --- a/middleware.ts +++ b/middleware.ts @@ -85,9 +85,20 @@ export async function middleware(request: NextRequest) { return NextResponse.redirect(redirectUrl) } - // Redirect to /epd/patients if authenticated and trying to access login + // Helper: get preferred interface redirect path + const getPreferredPath = () => { + const preference = user?.user_metadata?.preferred_interface + return preference === 'swift' ? '/epd/swift' : '/epd/clients' + } + + // Redirect to preferred interface if authenticated and trying to access login if (user && pathname === '/login') { - return NextResponse.redirect(new URL('/epd/patients', request.url)) + return NextResponse.redirect(new URL(getPreferredPath(), request.url)) + } + + // Redirect /epd to preferred interface based on user preference + if (user && pathname === '/epd') { + return NextResponse.redirect(new URL(getPreferredPath(), request.url)) } return supabaseResponse diff --git a/stores/swift-store.ts b/stores/swift-store.ts index bd7daf6..f7aca94 100644 --- a/stores/swift-store.ts +++ b/stores/swift-store.ts @@ -15,7 +15,7 @@ export type SwiftIntent = | 'overdracht' | 'unknown'; -export type BlockType = Exclude; +export type BlockType = Exclude | 'fallback'; // Extracted entities from user input export interface ExtractedEntities {