Skip to content

Commit 71825f0

Browse files
Clément VALENTINclaude
andcommitted
feat(web): unify notification system with custom Zustand toast
- Create notificationStore.ts with Zustand for toast state management - Create Toast.tsx component with animations and dark mode support - Migrate all pages from react-hot-toast to custom toast system - Add toast.loading() support with spinning indicator - Remove react-hot-toast dependency from package.json - Remove Toaster component from Layout.tsx - Position toasts top center with auto-dismiss per type 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 9c56c38 commit 71825f0

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+346
-447
lines changed

apps/web/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
"react-day-picker": "^9.12.0",
3030
"react-dom": "^18.3.1",
3131
"react-helmet-async": "^2.0.5",
32-
"react-hot-toast": "^2.6.0",
3332
"react-router-dom": "^6.28.0",
3433
"recharts": "^3.2.1",
3534
"swagger-ui-react": "^5.29.3",

apps/web/src/App.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
22
import { Suspense, lazy } from 'react'
33
import { useAuth } from './hooks/useAuth'
44
import Layout from './components/Layout'
5+
import ToastContainer from './components/Toast'
56
import PermissionRoute from './components/PermissionRoute'
67
import Landing from './pages/Landing'
78
import Login from './pages/Login'
@@ -75,7 +76,9 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
7576

7677
function App() {
7778
return (
78-
<Routes>
79+
<>
80+
<ToastContainer />
81+
<Routes>
7982
{/* Public routes */}
8083
<Route path="/" element={<Landing />} />
8184
<Route
@@ -410,6 +413,7 @@ function App() {
410413
{/* 404 - catch all */}
411414
<Route path="*" element={<NotFound />} />
412415
</Routes>
416+
</>
413417
)
414418
}
415419

apps/web/src/components/DetailedCurve.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useState, useEffect, useRef } from 'react'
22
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
33
import { Calendar, Download, BarChart3, Loader2, CalendarDays, CalendarRange } from 'lucide-react'
4-
import toast from 'react-hot-toast'
4+
import { toast } from '@/stores/notificationStore'
55
import { useQueryClient } from '@tanstack/react-query'
66
import { logger } from '@/utils/logger'
77
import { useResponsiveDayCount } from '@/hooks/useResponsiveDayCount'

apps/web/src/components/Layout.tsx

Lines changed: 2 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useAuth } from '@/hooks/useAuth'
44
import { usePermissions } from '@/hooks/usePermissions'
55
import { useThemeStore } from '@/stores/themeStore'
66
import { useState, useEffect } from 'react'
7-
import toast, { Toaster } from 'react-hot-toast'
7+
import { toast } from '@/stores/notificationStore'
88
import AdminTabs from './AdminTabs'
99
import ApiDocsTabs from './ApiDocsTabs'
1010
import ConsumptionTabs from './ConsumptionTabs'
@@ -181,7 +181,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
181181
target.closest('div[style*="gradient"]')
182182

183183
if (isToast) {
184-
toast.dismiss()
184+
toast.clearAll()
185185
}
186186
}
187187

@@ -808,54 +808,6 @@ export default function Layout({ children }: { children: React.ReactNode }) {
808808
</footer>
809809
</div>
810810

811-
{/* Global Toaster */}
812-
<Toaster
813-
position="top-center"
814-
toastOptions={{
815-
duration: 5000,
816-
style: {
817-
background: 'linear-gradient(135deg, #4338ca 0%, #3730a3 100%)',
818-
color: '#fff',
819-
border: 'none',
820-
borderRadius: '0.75rem',
821-
padding: '1rem 1.5rem',
822-
fontSize: '0.875rem',
823-
fontWeight: '500',
824-
cursor: 'pointer',
825-
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.2)',
826-
minWidth: '400px',
827-
maxWidth: '600px',
828-
},
829-
success: {
830-
iconTheme: {
831-
primary: '#fff',
832-
secondary: '#10b981',
833-
},
834-
style: {
835-
background: 'linear-gradient(135deg, #059669 0%, #047857 100%)',
836-
minWidth: '400px',
837-
maxWidth: '600px',
838-
},
839-
},
840-
error: {
841-
iconTheme: {
842-
primary: '#fff',
843-
secondary: '#ef4444',
844-
},
845-
style: {
846-
background: 'linear-gradient(135deg, #dc2626 0%, #b91c1c 100%)',
847-
minWidth: '400px',
848-
maxWidth: '600px',
849-
},
850-
},
851-
}}
852-
containerStyle={{
853-
top: 10,
854-
left: '50%',
855-
right: 'auto',
856-
transform: sidebarCollapsed ? 'translateX(calc(-50% + 2rem))' : 'translateX(calc(-50% + 7rem))',
857-
}}
858-
/>
859811
</div>
860812
)
861813
}

apps/web/src/components/PageHeader.tsx

Lines changed: 17 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { useEffect, useMemo } from 'react'
22
import { useLocation } from 'react-router-dom'
33
import { TrendingUp, Sun, Calculator, Download, Lock, LayoutDashboard, Calendar, Zap, Users, AlertCircle, BookOpen, Settings as SettingsIcon, Key, Shield, FileText, Activity, Euro, Scale, UserCheck } from 'lucide-react'
44
import { useQuery } from '@tanstack/react-query'
5-
import toast from 'react-hot-toast'
65
import { pdlApi } from '@/api/pdl'
76
import { adminApi } from '@/api/admin'
87
import { usePdlStore } from '@/stores/pdlStore'
@@ -73,7 +72,7 @@ export default function PageHeader() {
7372
}, [isAdmin, impersonation, clearImpersonation])
7473

7574
// Récupérer la liste des PDLs de l'utilisateur
76-
const { data: pdlsResponse } = useQuery({
75+
const { data: pdlsResponse, isLoading: isPdlsLoading } = useQuery({
7776
queryKey: ['pdls'],
7877
queryFn: async () => {
7978
const response = await pdlApi.list()
@@ -85,7 +84,7 @@ export default function PageHeader() {
8584
})
8685

8786
// Récupérer les PDL partagés (admin only)
88-
const { data: sharedPdlsResponse } = useQuery({
87+
const { data: sharedPdlsResponse, isLoading: isSharedPdlsLoading } = useQuery({
8988
queryKey: ['admin-shared-pdls'],
9089
queryFn: async () => {
9190
const response = await adminApi.getAllSharedPdls()
@@ -98,6 +97,9 @@ export default function PageHeader() {
9897
staleTime: 60000, // Cache for 1 minute
9998
})
10099

100+
// Determine if initial data is still loading
101+
const isInitialDataLoading = isPdlsLoading || (isAdmin() && isSharedPdlsLoading)
102+
101103
const userPdls: PDL[] = Array.isArray(pdlsResponse) ? pdlsResponse : []
102104
const sharedPdls: SharedPDL[] = Array.isArray(sharedPdlsResponse) ? sharedPdlsResponse : []
103105

@@ -156,10 +158,6 @@ export default function PageHeader() {
156158
if (!config) return null
157159

158160
const Icon = config.icon
159-
const activePdls = pdls.filter((p: PDL) => p.is_active)
160-
161-
// Check if on a consumption page
162-
const isConsumptionPage = location.pathname.startsWith('/consumption')
163161

164162
// Get the set of production PDL IDs that are linked to consumption PDLs
165163
// These should be hidden from the selector (the consumption PDL will show instead)
@@ -169,47 +167,21 @@ export default function PageHeader() {
169167
.map((pdl: PDL) => pdl.linked_production_pdl_id)
170168
)
171169

172-
// Filter PDLs based on page
173-
const displayedPdls = location.pathname === '/production'
174-
? (() => {
175-
// Show: consumption PDLs with linked production + standalone production PDLs
176-
const consumptionWithProduction = pdls.filter((pdl: PDL) =>
177-
pdl.has_consumption &&
178-
pdl.is_active &&
179-
pdl.linked_production_pdl_id
180-
)
181-
182-
const standaloneProduction = activePdls.filter((pdl: PDL) =>
183-
pdl.has_production &&
184-
!linkedProductionIds.has(pdl.id) // Use pdl.id (UUID) not usage_point_id
185-
)
170+
// Show all active PDLs, hiding only linked production PDLs (they appear via their consumption PDL)
171+
const displayedPdls = pdls.filter((p: PDL) => p.is_active && !linkedProductionIds.has(p.id))
186172

187-
return [...consumptionWithProduction, ...standaloneProduction]
188-
})()
189-
: isConsumptionPage
190-
? activePdls.filter((pdl: PDL) => pdl.has_consumption)
191-
: activePdls.filter((pdl: PDL) => !linkedProductionIds.has(pdl.id)) // Hide linked production PDLs globally
192-
193-
// Auto-select first PDL if none selected OR if current PDL is not in the displayed list
173+
// Auto-select first PDL only if none is selected (first load)
174+
// IMPORTANT: Wait for initial data to be loaded before auto-selecting to avoid
175+
// overriding the persisted selectedPdl from localStorage during hydration
194176
useEffect(() => {
195-
if (displayedPdls.length > 0) {
196-
// Check if current PDL is in the displayed list for this page
197-
const currentPdlInList = displayedPdls.some(p => p.usage_point_id === selectedPdl)
177+
// Don't auto-select while data is still loading
178+
if (isInitialDataLoading) return
198179

199-
if (!selectedPdl || !currentPdlInList) {
200-
const newPdl = displayedPdls[0]
201-
// Show toast only if we're switching from an incompatible PDL (not on first load)
202-
if (selectedPdl && !currentPdlInList) {
203-
toast(`PDL changé automatiquement vers "${newPdl.name || newPdl.usage_point_id}"`, {
204-
icon: '🔄',
205-
duration: 4000,
206-
})
207-
}
208-
// Select the first available PDL for this page
209-
setSelectedPdl(newPdl.usage_point_id)
210-
}
180+
// Only auto-select if no PDL is currently selected
181+
if (!selectedPdl && displayedPdls.length > 0) {
182+
setSelectedPdl(displayedPdls[0].usage_point_id)
211183
}
212-
}, [displayedPdls, selectedPdl, setSelectedPdl])
184+
}, [displayedPdls, selectedPdl, setSelectedPdl, isInitialDataLoading])
213185

214186
return (
215187
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
@@ -234,11 +206,7 @@ export default function PageHeader() {
234206
{showPdlSelector && (
235207
displayedPdls.length === 0 ? (
236208
<div className="text-sm text-gray-600 dark:text-gray-400 italic">
237-
{location.pathname === '/production'
238-
? 'Aucun PDL de production non lié trouvé'
239-
: isConsumptionPage
240-
? 'Aucun PDL avec l\'option consommation activée'
241-
: 'Aucun point de livraison actif trouvé'}
209+
Aucun point de livraison actif trouvé
242210
</div>
243211
) : (
244212
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 sm:gap-3 w-full lg:w-auto">

apps/web/src/components/Toast.tsx

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { useEffect, useState } from 'react'
2+
import { createPortal } from 'react-dom'
3+
import { CheckCircle, XCircle, AlertCircle, Info, X, Loader2 } from 'lucide-react'
4+
import { useNotificationStore, type Toast as ToastType, type NotificationType } from '../stores/notificationStore'
5+
6+
// Icon and color configurations per type
7+
const TOAST_CONFIG: Record<
8+
NotificationType,
9+
{
10+
icon: typeof CheckCircle
11+
bgClass: string
12+
borderClass: string
13+
textClass: string
14+
iconClass: string
15+
}
16+
> = {
17+
success: {
18+
icon: CheckCircle,
19+
bgClass: 'bg-green-50 dark:bg-green-900/20',
20+
borderClass: 'border-green-200 dark:border-green-800',
21+
textClass: 'text-green-800 dark:text-green-200',
22+
iconClass: 'text-green-600 dark:text-green-400',
23+
},
24+
error: {
25+
icon: XCircle,
26+
bgClass: 'bg-red-50 dark:bg-red-900/20',
27+
borderClass: 'border-red-200 dark:border-red-800',
28+
textClass: 'text-red-800 dark:text-red-200',
29+
iconClass: 'text-red-600 dark:text-red-400',
30+
},
31+
warning: {
32+
icon: AlertCircle,
33+
bgClass: 'bg-yellow-50 dark:bg-yellow-900/20',
34+
borderClass: 'border-yellow-200 dark:border-yellow-800',
35+
textClass: 'text-yellow-800 dark:text-yellow-200',
36+
iconClass: 'text-yellow-600 dark:text-yellow-400',
37+
},
38+
info: {
39+
icon: Info,
40+
bgClass: 'bg-blue-50 dark:bg-blue-900/20',
41+
borderClass: 'border-blue-200 dark:border-blue-800',
42+
textClass: 'text-blue-800 dark:text-blue-200',
43+
iconClass: 'text-blue-600 dark:text-blue-400',
44+
},
45+
loading: {
46+
icon: Loader2,
47+
bgClass: 'bg-gray-50 dark:bg-gray-800/50',
48+
borderClass: 'border-gray-200 dark:border-gray-700',
49+
textClass: 'text-gray-800 dark:text-gray-200',
50+
iconClass: 'text-primary-600 dark:text-primary-400 animate-spin',
51+
},
52+
}
53+
54+
interface ToastItemProps {
55+
toast: ToastType
56+
onRemove: (id: string) => void
57+
}
58+
59+
function ToastItem({ toast, onRemove }: ToastItemProps) {
60+
const [isVisible, setIsVisible] = useState(false)
61+
const [isLeaving, setIsLeaving] = useState(false)
62+
63+
const config = TOAST_CONFIG[toast.type]
64+
const Icon = config.icon
65+
66+
useEffect(() => {
67+
// Trigger enter animation
68+
const enterTimer = setTimeout(() => setIsVisible(true), 10)
69+
return () => clearTimeout(enterTimer)
70+
}, [])
71+
72+
const handleRemove = () => {
73+
setIsLeaving(true)
74+
setTimeout(() => onRemove(toast.id), 200)
75+
}
76+
77+
return (
78+
<div
79+
className={`
80+
flex items-start gap-3 p-4 rounded-lg border shadow-lg backdrop-blur-sm
81+
transition-all duration-200 ease-out
82+
${config.bgClass} ${config.borderClass}
83+
${isVisible && !isLeaving ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-2'}
84+
min-w-[320px] max-w-[480px]
85+
`}
86+
role="alert"
87+
aria-live="polite"
88+
>
89+
<Icon className={`${config.iconClass} flex-shrink-0 mt-0.5`} size={20} />
90+
<p className={`${config.textClass} font-medium flex-1 text-sm`}>{toast.message}</p>
91+
{toast.dismissible && (
92+
<button
93+
onClick={handleRemove}
94+
className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors flex-shrink-0"
95+
aria-label="Fermer"
96+
>
97+
<X size={18} />
98+
</button>
99+
)}
100+
</div>
101+
)
102+
}
103+
104+
export function ToastContainer() {
105+
const toasts = useNotificationStore((state) => state.toasts)
106+
const removeToast = useNotificationStore((state) => state.removeToast)
107+
108+
// Don't render anything if no toasts
109+
if (toasts.length === 0) return null
110+
111+
return createPortal(
112+
<div
113+
className="fixed top-4 left-1/2 -translate-x-1/2 z-50 flex flex-col gap-2 pointer-events-none"
114+
aria-label="Notifications"
115+
>
116+
{toasts.map((toast) => (
117+
<div key={toast.id} className="pointer-events-auto">
118+
<ToastItem toast={toast} onRemove={removeToast} />
119+
</div>
120+
))}
121+
</div>,
122+
document.body
123+
)
124+
}
125+
126+
export default ToastContainer

apps/web/src/hooks/useDataFetch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useState, useCallback } from 'react'
22
import { useQueryClient } from '@tanstack/react-query'
33
import { enedisApi } from '@/api/enedis'
44
import { logger } from '@/utils/logger'
5-
import toast from 'react-hot-toast'
5+
import { toast } from '@/stores/notificationStore'
66
import type { PDL } from '@/types/api'
77

88
interface UseDataFetchResult {

apps/web/src/hooks/useUnifiedDataFetch.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useCallback } from 'react'
22
import { useQueryClient } from '@tanstack/react-query'
33
import { enedisApi } from '@/api/enedis'
44
import { logger } from '@/utils/logger'
5-
import toast from 'react-hot-toast'
5+
import { toast } from '@/stores/notificationStore'
66
import type { PDL } from '@/types/api'
77
import { useDataFetchStore } from '@/stores/dataFetchStore'
88
import { useCacheBroadcast } from './useCacheBroadcast'
@@ -205,7 +205,7 @@ export function useUnifiedDataFetch({
205205
} else if (batchData?.error) {
206206
updateConsumptionStatus({ detail: 'error' })
207207
if (batchData.error.code === 'PARTIAL_DATA') {
208-
toast.success(batchData.error.message, { duration: 4000, icon: '⚠️' })
208+
toast.warning(batchData.error.message, { duration: 4000 })
209209
} else {
210210
toast.error(batchData.error.message || 'Erreur données détaillées de consommation')
211211
}
@@ -322,7 +322,7 @@ export function useUnifiedDataFetch({
322322
} else if (batchData?.error) {
323323
updateProductionStatus({ detail: 'error' })
324324
if (batchData.error.code === 'PARTIAL_DATA') {
325-
toast.success(batchData.error.message, { duration: 4000, icon: '⚠️' })
325+
toast.warning(batchData.error.message, { duration: 4000 })
326326
} else {
327327
toast.error(batchData.error.message || 'Erreur données détaillées de production')
328328
}

0 commit comments

Comments
 (0)