create-crm-tmp 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/bin/create-crm-tmp.js +56 -35
  2. package/package.json +1 -1
  3. package/template/README.md +230 -115
  4. package/template/eslint.config.mjs +13 -0
  5. package/template/next.config.ts +14 -0
  6. package/template/package.json +15 -2
  7. package/template/prisma/migrations/20260318095700_init_db/migration.sql +978 -0
  8. package/template/prisma/migrations/migration_lock.toml +3 -0
  9. package/template/prisma/schema.prisma +132 -637
  10. package/template/src/app/(auth)/invite/[token]/page.tsx +10 -8
  11. package/template/src/app/(auth)/layout.tsx +1 -1
  12. package/template/src/app/(auth)/reset-password/complete/page.tsx +11 -8
  13. package/template/src/app/(auth)/reset-password/page.tsx +4 -4
  14. package/template/src/app/(auth)/reset-password/verify/page.tsx +4 -4
  15. package/template/src/app/(auth)/signin/page.tsx +14 -6
  16. package/template/src/app/(dashboard)/agenda/page.tsx +2243 -988
  17. package/template/src/app/(dashboard)/automatisation/_components/workflow-editor.tsx +18 -104
  18. package/template/src/app/(dashboard)/automatisation/page.tsx +10 -26
  19. package/template/src/app/(dashboard)/closing/page.tsx +78 -62
  20. package/template/src/app/(dashboard)/contacts/[id]/page.tsx +2082 -1080
  21. package/template/src/app/(dashboard)/contacts/companies/[id]/page.tsx +46 -47
  22. package/template/src/app/(dashboard)/contacts/page.tsx +1062 -780
  23. package/template/src/app/(dashboard)/dashboard/page.tsx +533 -37
  24. package/template/src/app/(dashboard)/dev/page.tsx +1291 -0
  25. package/template/src/app/(dashboard)/layout.tsx +6 -2
  26. package/template/src/app/(dashboard)/settings/page.tsx +797 -2582
  27. package/template/src/app/(dashboard)/templates/page.tsx +55 -54
  28. package/template/src/app/(dashboard)/users/list/page.tsx +51 -48
  29. package/template/src/app/(dashboard)/users/page.tsx +1 -1
  30. package/template/src/app/(dashboard)/users/permissions/page.tsx +2 -2
  31. package/template/src/app/(dashboard)/users/roles/page.tsx +7 -5
  32. package/template/src/app/api/agenda/google-events/route.ts +92 -0
  33. package/template/src/app/api/auth/check-active/route.ts +3 -2
  34. package/template/src/app/api/auth/google/route.ts +2 -1
  35. package/template/src/app/api/auth/google/status/route.ts +7 -31
  36. package/template/src/app/api/companies/[id]/activities/route.ts +1 -3
  37. package/template/src/app/api/companies/[id]/route.ts +1 -2
  38. package/template/src/app/api/companies/route.ts +42 -12
  39. package/template/src/app/api/contacts/[id]/files/[fileId]/preview/route.ts +9 -31
  40. package/template/src/app/api/contacts/[id]/files/[fileId]/route.ts +14 -32
  41. package/template/src/app/api/contacts/[id]/files/route.ts +112 -212
  42. package/template/src/app/api/contacts/[id]/interactions/[interactionId]/route.ts +27 -1
  43. package/template/src/app/api/contacts/[id]/interactions/route.ts +16 -16
  44. package/template/src/app/api/contacts/[id]/kyc/route.ts +21 -11
  45. package/template/src/app/api/contacts/[id]/meet/route.ts +19 -2
  46. package/template/src/app/api/contacts/[id]/route.ts +106 -34
  47. package/template/src/app/api/contacts/[id]/send-email/route.ts +27 -11
  48. package/template/src/app/api/contacts/[id]/workflows/run/route.ts +6 -0
  49. package/template/src/app/api/contacts/export/route.ts +9 -13
  50. package/template/src/app/api/contacts/import/route.ts +55 -25
  51. package/template/src/app/api/contacts/import-preview/route.ts +1 -1
  52. package/template/src/app/api/contacts/origins/route.ts +63 -0
  53. package/template/src/app/api/contacts/route.ts +153 -41
  54. package/template/src/app/api/cron/cleanup-editor-images/route.ts +166 -0
  55. package/template/src/app/api/dashboard/widgets/[id]/route.ts +44 -0
  56. package/template/src/app/api/dashboard/widgets/route.ts +181 -0
  57. package/template/src/app/api/dev/reminders/test/route.ts +114 -0
  58. package/template/src/app/api/editor/upload-image/route.ts +61 -0
  59. package/template/src/app/api/integrations/google-sheet/jobs/[jobId]/route.ts +47 -0
  60. package/template/src/app/api/integrations/google-sheet/jobs/usage/route.ts +50 -0
  61. package/template/src/app/api/integrations/google-sheet/sync/route.ts +24 -556
  62. package/template/src/app/api/jobs/google-sheet/process/route.ts +84 -0
  63. package/template/src/app/api/jobs/google-sheet/schedule/route.ts +50 -0
  64. package/template/src/app/api/reminders/clear/route.ts +120 -0
  65. package/template/src/app/api/reminders/clear/undo/route.ts +112 -0
  66. package/template/src/app/api/reminders/route.ts +164 -39
  67. package/template/src/app/api/reminders/state/route.ts +164 -0
  68. package/template/src/app/api/reset-password/request/route.ts +1 -1
  69. package/template/src/app/api/reset-password/verify/route.ts +1 -1
  70. package/template/src/app/api/send/route.ts +16 -4
  71. package/template/src/app/api/settings/google-ads/route.ts +14 -0
  72. package/template/src/app/api/settings/google-calendar/calendars/route.ts +97 -0
  73. package/template/src/app/api/settings/google-calendar/route.ts +124 -0
  74. package/template/src/app/api/settings/google-sheet/[id]/route.ts +28 -0
  75. package/template/src/app/api/settings/google-sheet/auto-map/route.ts +37 -4
  76. package/template/src/app/api/settings/google-sheet/preview/route.ts +9 -3
  77. package/template/src/app/api/settings/google-sheet/route.ts +14 -0
  78. package/template/src/app/api/settings/integrations/logs/route.ts +93 -0
  79. package/template/src/app/api/settings/integrations/notifications/route.ts +67 -0
  80. package/template/src/app/api/settings/meta-leads/[id]/route.ts +0 -1
  81. package/template/src/app/api/settings/meta-leads/route.ts +14 -2
  82. package/template/src/app/api/settings/smtp/route.ts +53 -6
  83. package/template/src/app/api/tasks/[id]/attendees/route.ts +24 -8
  84. package/template/src/app/api/tasks/[id]/route.ts +234 -58
  85. package/template/src/app/api/tasks/meet/route.ts +27 -19
  86. package/template/src/app/api/tasks/route.ts +62 -17
  87. package/template/src/app/api/users/[id]/route.ts +20 -14
  88. package/template/src/app/api/users/list/route.ts +57 -19
  89. package/template/src/app/api/webhooks/google-ads/route.ts +34 -14
  90. package/template/src/app/api/webhooks/meta-leads/route.ts +32 -12
  91. package/template/src/app/api/workflows/[id]/route.ts +0 -4
  92. package/template/src/app/api/workflows/process/route.ts +22 -51
  93. package/template/src/app/api/workflows/route.ts +0 -4
  94. package/template/src/app/globals.css +342 -4
  95. package/template/src/app/layout.tsx +11 -3
  96. package/template/src/app/page.tsx +1 -1
  97. package/template/src/components/address-autocomplete.tsx +7 -6
  98. package/template/src/components/config-error-alert.tsx +46 -0
  99. package/template/src/components/contacts/filter-bar.tsx +12 -3
  100. package/template/src/components/contacts/filter-builder.tsx +28 -43
  101. package/template/src/components/contacts/save-view-dialog.tsx +1 -1
  102. package/template/src/components/contacts/views-tab-bar.tsx +15 -6
  103. package/template/src/components/dashboard/activity-chart.tsx +41 -28
  104. package/template/src/components/dashboard/add-widget-dialog.tsx +157 -0
  105. package/template/src/components/dashboard/color-picker.tsx +64 -0
  106. package/template/src/components/dashboard/contacts-chart.tsx +69 -0
  107. package/template/src/components/dashboard/interactions-by-type-chart.tsx +121 -0
  108. package/template/src/components/dashboard/recent-activity.tsx +154 -0
  109. package/template/src/components/dashboard/stat-card.tsx +40 -40
  110. package/template/src/components/dashboard/status-distribution-chart.tsx +81 -0
  111. package/template/src/components/dashboard/tasks-pie-chart.tsx +37 -34
  112. package/template/src/components/dashboard/top-contacts-list.tsx +113 -0
  113. package/template/src/components/dashboard/upcoming-tasks-list.tsx +72 -81
  114. package/template/src/components/dashboard/widget-wrapper.tsx +36 -0
  115. package/template/src/components/date-picker.tsx +9 -6
  116. package/template/src/components/editor/upload-editor-image.ts +42 -0
  117. package/template/src/components/editor.tsx +161 -22
  118. package/template/src/components/email-template.tsx +2 -2
  119. package/template/src/components/global-search.tsx +30 -28
  120. package/template/src/components/header.tsx +178 -80
  121. package/template/src/components/inactive-account-guard.tsx +58 -0
  122. package/template/src/components/integration-notifications-listener.tsx +12 -0
  123. package/template/src/components/invitation-email-template.tsx +2 -2
  124. package/template/src/components/meet-cancellation-email-template.tsx +3 -3
  125. package/template/src/components/meet-confirmation-email-template.tsx +3 -3
  126. package/template/src/components/meet-update-email-template.tsx +3 -3
  127. package/template/src/components/page-header.tsx +5 -5
  128. package/template/src/components/protected-page.tsx +1 -1
  129. package/template/src/components/reset-password-email-template.tsx +2 -2
  130. package/template/src/components/settings/integrations/GoogleAdsIntegration.tsx +428 -0
  131. package/template/src/components/settings/integrations/GoogleSheetConfigMonitoringModal.tsx +680 -0
  132. package/template/src/components/settings/integrations/GoogleSheetIntegration.tsx +809 -0
  133. package/template/src/components/settings/integrations/ImportResultDialog.tsx +124 -0
  134. package/template/src/components/settings/integrations/IntegrationLogPanel.tsx +57 -0
  135. package/template/src/components/settings/integrations/IntegrationLogsTable.tsx +186 -0
  136. package/template/src/components/settings/integrations/MetaLeadIntegration.tsx +451 -0
  137. package/template/src/components/sidebar.tsx +45 -26
  138. package/template/src/components/skeleton.tsx +40 -43
  139. package/template/src/components/ui/accordion.tsx +2 -2
  140. package/template/src/components/ui/alert-dialog.tsx +1 -1
  141. package/template/src/components/ui/button.tsx +20 -9
  142. package/template/src/components/ui/components.tsx +1 -1
  143. package/template/src/components/ui/date-picker.tsx +422 -0
  144. package/template/src/components/ui/datetime-picker.tsx +338 -0
  145. package/template/src/components/ui/status-select.tsx +271 -0
  146. package/template/src/components/ui/tooltip.tsx +37 -0
  147. package/template/src/components/view-as-modal.tsx +13 -7
  148. package/template/src/contexts/app-toast-context.tsx +245 -57
  149. package/template/src/contexts/dashboard-theme-context.tsx +53 -0
  150. package/template/src/contexts/sidebar-context.tsx +22 -17
  151. package/template/src/contexts/task-reminder-context.tsx +134 -160
  152. package/template/src/contexts/view-as-context.tsx +33 -6
  153. package/template/src/hooks/use-focus-trap.ts +2 -2
  154. package/template/src/hooks/useIntegrationNotifications.ts +49 -0
  155. package/template/src/lib/auth.ts +8 -1
  156. package/template/src/lib/config-links.ts +14 -0
  157. package/template/src/lib/contact-duplicate.ts +79 -61
  158. package/template/src/lib/contact-interactions.ts +21 -21
  159. package/template/src/lib/contact-view-filters.ts +24 -64
  160. package/template/src/lib/contacts-list-url.ts +190 -0
  161. package/template/src/lib/dashboard-stats.ts +65 -7
  162. package/template/src/lib/dashboard-themes.ts +135 -0
  163. package/template/src/lib/date-utils.ts +127 -0
  164. package/template/src/lib/default-widgets.ts +12 -0
  165. package/template/src/lib/editor-html-image-dimensions.ts +172 -0
  166. package/template/src/lib/editor-image-limits.ts +19 -0
  167. package/template/src/lib/email-html-sanitize.ts +19 -0
  168. package/template/src/lib/encryption.ts +9 -6
  169. package/template/src/lib/fr-geography.ts +192 -0
  170. package/template/src/lib/google-calendar-agenda.ts +201 -0
  171. package/template/src/lib/google-calendar.ts +255 -5
  172. package/template/src/lib/google-sheet-sync-jobs.ts +96 -0
  173. package/template/src/lib/google-sheet-sync-runner.ts +514 -0
  174. package/template/src/lib/integration-import-log.ts +21 -0
  175. package/template/src/lib/permissions.ts +40 -10
  176. package/template/src/lib/prisma.ts +4 -1
  177. package/template/src/lib/qstash.ts +65 -0
  178. package/template/src/lib/reminder-state-server.ts +80 -0
  179. package/template/src/lib/reminder-state.ts +29 -0
  180. package/template/src/lib/supabase-storage.ts +113 -0
  181. package/template/src/lib/template-variables.ts +164 -23
  182. package/template/src/lib/utils.ts +45 -0
  183. package/template/src/lib/widget-registry.ts +173 -0
  184. package/template/src/lib/workflow-executor.ts +16 -70
  185. package/template/src/proxy.ts +1 -0
  186. package/template/vercel.json +3 -10
  187. package/template/skills-lock.json +0 -25
  188. package/template/src/components/dashboard/dashboard-content.tsx +0 -79
  189. package/template/src/lib/google-drive.ts +0 -1101
  190. package/template/src/types/yousign.ts +0 -52
@@ -1,15 +1,36 @@
1
1
  'use client';
2
2
 
3
3
  import { CheckCircle2, Info, TriangleAlert, X, XCircle } from 'lucide-react';
4
- import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
4
+ import Link from 'next/link';
5
+ import {
6
+ createContext,
7
+ useCallback,
8
+ useContext,
9
+ useEffect,
10
+ useMemo,
11
+ useRef,
12
+ useState,
13
+ } from 'react';
14
+ import { AnimatePresence, motion } from 'motion/react';
5
15
  import { cn } from '@/lib/utils';
6
16
 
7
17
  type ToastTone = 'success' | 'error' | 'info' | 'warning';
18
+ type ToastExitType = 'slide' | 'fade';
8
19
 
9
20
  interface ToastItem {
10
21
  id: string;
11
22
  message: string;
12
23
  tone: ToastTone;
24
+ /** Lien vers la config - affiché dans le toast. Si présent, le toast est persistant (pas de fermeture auto). */
25
+ actionLink?: string;
26
+ actionLabel?: string;
27
+ /** Action secondaire (bouton) — ex. Annuler après une action destructive */
28
+ actionOnClick?: () => void | Promise<void>;
29
+ isPersistent?: boolean;
30
+ onDismiss?: () => void | Promise<void>;
31
+ /** Phase de sortie : déclenche l'animation avant suppression */
32
+ isExiting?: boolean;
33
+ exitType?: ToastExitType;
13
34
  }
14
35
 
15
36
  interface ToastContextValue {
@@ -17,29 +38,46 @@ interface ToastContextValue {
17
38
  error: (message: string) => void;
18
39
  info: (message: string) => void;
19
40
  warning: (message: string) => void;
41
+ /** Toast persistant avec lien de configuration - ne se ferme que sur clic du lien ou de la croix */
42
+ errorConfigRequired: (message: string, configLink: string) => void;
43
+ persistent: (
44
+ tone: ToastTone,
45
+ message: string,
46
+ options?: {
47
+ actionLink?: string;
48
+ actionLabel?: string;
49
+ actionOnClick?: () => void | Promise<void>;
50
+ onDismiss?: () => void | Promise<void>;
51
+ /** Fermeture auto même pour un toast « persistant » (ex. fenêtre undo courte) */
52
+ autoDismissMs?: number;
53
+ },
54
+ ) => string;
55
+ dismissById: (id: string) => void;
20
56
  }
21
57
 
22
58
  const ToastContext = createContext<ToastContextValue | null>(null);
23
59
 
24
- const TOAST_STYLES: Record<ToastTone, { icon: React.ComponentType<{ className?: string }>; className: string }> =
25
- {
26
- success: {
27
- icon: CheckCircle2,
28
- className: 'border-primary/30 bg-primary text-primary-foreground',
29
- },
30
- error: {
31
- icon: XCircle,
32
- className: 'border-destructive/30 bg-destructive text-white',
33
- },
34
- info: {
35
- icon: Info,
36
- className: 'border-border bg-card text-foreground',
37
- },
38
- warning: {
39
- icon: TriangleAlert,
40
- className: 'border-blue-400/40 bg-blue-500 text-blue-950',
41
- },
42
- };
60
+ const TOAST_STYLES: Record<
61
+ ToastTone,
62
+ { icon: React.ComponentType<{ className?: string }>; className: string }
63
+ > = {
64
+ success: {
65
+ icon: CheckCircle2,
66
+ className: 'border-primary/30 bg-primary text-white',
67
+ },
68
+ error: {
69
+ icon: XCircle,
70
+ className: 'border-destructive/30 bg-destructive text-white',
71
+ },
72
+ info: {
73
+ icon: Info,
74
+ className: 'border-blue-600/30 bg-blue-600 text-white',
75
+ },
76
+ warning: {
77
+ icon: TriangleAlert,
78
+ className: 'border-blue-400/40 bg-blue-600 text-white',
79
+ },
80
+ };
43
81
 
44
82
  const TOAST_DURATION_MS: Record<ToastTone, number> = {
45
83
  success: 4500,
@@ -58,7 +96,7 @@ export function AppToastProvider({ children }: Readonly<{ children: React.ReactN
58
96
  const expiresAtRef = useRef<Record<string, number>>({});
59
97
  const remainingMsRef = useRef<Record<string, number>>({});
60
98
 
61
- const dismiss = useCallback((id: string) => {
99
+ const dismiss = useCallback((id: string, exitType: ToastExitType = 'slide') => {
62
100
  const timeout = timersRef.current[id];
63
101
  if (timeout) {
64
102
  clearTimeout(timeout);
@@ -66,7 +104,28 @@ export function AppToastProvider({ children }: Readonly<{ children: React.ReactN
66
104
  }
67
105
  delete expiresAtRef.current[id];
68
106
  delete remainingMsRef.current[id];
69
- setToasts((prev) => prev.filter((toast) => toast.id !== id));
107
+
108
+ let onDismissCb: (() => void | Promise<void>) | undefined;
109
+ setToasts((prev) => {
110
+ const t = prev.find((x) => x.id === id);
111
+ if (t?.isExiting) return prev;
112
+ if (t?.onDismiss) {
113
+ onDismissCb = t.onDismiss;
114
+ }
115
+ return prev.map((x) =>
116
+ x.id === id ? { ...x, isExiting: true, exitType } : x,
117
+ );
118
+ });
119
+ if (onDismissCb) {
120
+ const cb = onDismissCb;
121
+ queueMicrotask(() => {
122
+ void cb();
123
+ });
124
+ }
125
+ }, []);
126
+
127
+ const removeToast = useCallback((id: string) => {
128
+ setToasts((prev) => prev.filter((t) => t.id !== id));
70
129
  }, []);
71
130
 
72
131
  const startTimer = useCallback(
@@ -102,11 +161,51 @@ export function AppToastProvider({ children }: Readonly<{ children: React.ReactN
102
161
  [dismiss, startTimer],
103
162
  );
104
163
 
105
- const push = useCallback((tone: ToastTone, message: string) => {
106
- const id = createToastId();
107
- setToasts((prev) => [...prev, { id, tone, message }]);
108
- startTimer(id, TOAST_DURATION_MS[tone]);
109
- }, [startTimer]);
164
+ const push = useCallback(
165
+ (
166
+ tone: ToastTone,
167
+ message: string,
168
+ options?: {
169
+ actionLink?: string;
170
+ actionLabel?: string;
171
+ actionOnClick?: () => void | Promise<void>;
172
+ persistent?: boolean;
173
+ onDismiss?: () => void | Promise<void>;
174
+ autoDismissMs?: number;
175
+ },
176
+ ): string => {
177
+ const id = createToastId();
178
+ const item: ToastItem = { id, tone, message };
179
+ if (options?.actionLink) item.actionLink = options.actionLink;
180
+ if (options?.actionLabel) item.actionLabel = options.actionLabel;
181
+ if (options?.actionOnClick) item.actionOnClick = options.actionOnClick;
182
+ const needsPersistent =
183
+ Boolean(options?.persistent) ||
184
+ Boolean(options?.actionLink || options?.actionOnClick);
185
+ if (needsPersistent) item.isPersistent = true;
186
+ if (options?.onDismiss) item.onDismiss = options.onDismiss;
187
+ setToasts((prev) => [...prev, item]);
188
+ const auto = options?.autoDismissMs;
189
+ if (auto != null && auto > 0) {
190
+ startTimer(id, auto);
191
+ } else if (!item.isPersistent) {
192
+ startTimer(id, TOAST_DURATION_MS[tone]);
193
+ }
194
+ return id;
195
+ },
196
+ [startTimer],
197
+ );
198
+
199
+ const errorConfigRequired = useCallback(
200
+ (message: string, configLink: string) => {
201
+ push('error', message, {
202
+ actionLink: configLink,
203
+ actionLabel: 'Configurer dans les paramètres →',
204
+ persistent: true,
205
+ });
206
+ },
207
+ [push],
208
+ );
110
209
 
111
210
  useEffect(() => {
112
211
  return () => {
@@ -123,43 +222,132 @@ export function AppToastProvider({ children }: Readonly<{ children: React.ReactN
123
222
  error: (message) => push('error', message),
124
223
  info: (message) => push('info', message),
125
224
  warning: (message) => push('warning', message),
225
+ errorConfigRequired,
226
+ persistent: (tone, message, options) =>
227
+ push(tone, message, {
228
+ actionLink: options?.actionLink,
229
+ actionLabel: options?.actionLabel,
230
+ actionOnClick: options?.actionOnClick,
231
+ onDismiss: options?.onDismiss,
232
+ autoDismissMs: options?.autoDismissMs,
233
+ persistent: true,
234
+ }),
235
+ dismissById: (id) => dismiss(id, 'fade'),
126
236
  }),
127
- [push],
237
+ [push, errorConfigRequired, dismiss],
128
238
  );
129
239
 
240
+ const PEEK = 10;
241
+ const MAX_STACK = 3;
242
+ const activeToasts = toasts.filter((t) => !t.isExiting);
243
+
130
244
  return (
131
245
  <ToastContext.Provider value={value}>
132
246
  {children}
133
- <div className="pointer-events-none fixed right-4 bottom-4 z-60 space-y-2">
134
- {toasts.map((toast) => {
135
- const { className, icon: Icon } = TOAST_STYLES[toast.tone];
136
- return (
137
- <div
138
- key={toast.id}
139
- className={cn(
140
- 'pointer-events-auto flex min-w-[280px] max-w-sm items-start gap-2 rounded-lg border px-3 py-2 text-sm shadow-(--shadow-dropdown) transition-all duration-200',
141
- className,
142
- )}
143
- role={toast.tone === 'error' || toast.tone === 'warning' ? 'alert' : 'status'}
144
- aria-live={toast.tone === 'error' || toast.tone === 'warning' ? 'assertive' : 'polite'}
145
- onMouseEnter={() => pauseTimer(toast.id)}
146
- onMouseLeave={() => resumeTimer(toast.id)}
147
- onFocusCapture={() => pauseTimer(toast.id)}
148
- onBlurCapture={() => resumeTimer(toast.id)}
149
- >
150
- <Icon className="mt-0.5 h-4 w-4 shrink-0" />
151
- <p className="flex-1">{toast.message}</p>
152
- <button
153
- type="button"
154
- onClick={() => dismiss(toast.id)}
155
- className="cursor-pointer rounded p-1 opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/40"
156
- aria-label="Fermer la notification"
247
+ <div
248
+ className="fixed right-4 bottom-4 z-60 grid"
249
+ style={{ gridTemplateAreas: "'stack'" }}
250
+ >
251
+ <AnimatePresence>
252
+ {toasts.map((toast) => {
253
+ const { className, icon: Icon } = TOAST_STYLES[toast.tone];
254
+ const isPersistent = Boolean(toast.isPersistent);
255
+ const isExiting = Boolean(toast.isExiting);
256
+ const exitType = toast.exitType ?? 'slide';
257
+
258
+ const activeIdx = activeToasts.findIndex((t) => t.id === toast.id);
259
+ const depth = isExiting ? 0 : Math.max(0, activeToasts.length - 1 - activeIdx);
260
+ const isHidden = depth >= MAX_STACK;
261
+
262
+ const exitAnimation =
263
+ exitType === 'fade'
264
+ ? { opacity: 0, scale: 0.95 }
265
+ : { x: 120, opacity: 0 };
266
+
267
+ const scale = 1 - depth * 0.04;
268
+ const y = -(depth * PEEK);
269
+
270
+ return (
271
+ <motion.div
272
+ key={toast.id}
273
+ layout
274
+ initial={{ x: 120, opacity: 0, scale: 0.95 }}
275
+ animate={
276
+ isExiting
277
+ ? exitAnimation
278
+ : {
279
+ x: 0,
280
+ y,
281
+ scale,
282
+ opacity: isHidden ? 0 : 1,
283
+ }
284
+ }
285
+ exit={{ x: 120, opacity: 0 }}
286
+ transition={{ type: 'spring', stiffness: 400, damping: 30 }}
287
+ onAnimationComplete={() => {
288
+ if (isExiting) removeToast(toast.id);
289
+ }}
290
+ style={{
291
+ gridArea: 'stack',
292
+ alignSelf: 'end',
293
+ zIndex: 100 - depth,
294
+ transformOrigin: 'bottom center',
295
+ pointerEvents: depth > 0 ? 'none' : 'auto',
296
+ }}
297
+ className={cn(
298
+ 'flex min-w-[280px] max-w-sm flex-col gap-2 rounded-2xl border px-3 py-2.5 text-sm shadow-lg',
299
+ className,
300
+ )}
301
+ role={toast.tone === 'error' || toast.tone === 'warning' ? 'alert' : 'status'}
302
+ aria-live={
303
+ toast.tone === 'error' || toast.tone === 'warning' ? 'assertive' : 'polite'
304
+ }
305
+ onMouseEnter={() => !isPersistent && depth === 0 && pauseTimer(toast.id)}
306
+ onMouseLeave={() => !isPersistent && depth === 0 && resumeTimer(toast.id)}
307
+ onFocusCapture={() => !isPersistent && depth === 0 && pauseTimer(toast.id)}
308
+ onBlurCapture={() => !isPersistent && depth === 0 && resumeTimer(toast.id)}
157
309
  >
158
- <X className="h-3.5 w-3.5" />
159
- </button>
160
- </div>
161
- );
162
- })}
310
+ <div className="flex items-start gap-2">
311
+ <Icon className="mt-0.5 h-4 w-4 shrink-0" />
312
+ <div className="min-w-0 flex-1">
313
+ <p className="font-medium">{toast.message}</p>
314
+ {toast.actionLink ? (
315
+ <Link
316
+ href={toast.actionLink}
317
+ onClick={() => dismiss(toast.id, 'fade')}
318
+ className="mt-1.5 inline-flex items-center font-medium text-white underline underline-offset-2 opacity-90 hover:opacity-100"
319
+ >
320
+ {toast.actionLabel || 'Voir le détail →'}
321
+ </Link>
322
+ ) : toast.actionOnClick && toast.actionLabel ? (
323
+ <button
324
+ type="button"
325
+ onClick={async () => {
326
+ try {
327
+ await toast.actionOnClick?.();
328
+ } finally {
329
+ dismiss(toast.id, 'fade');
330
+ }
331
+ }}
332
+ className="mt-1.5 inline-flex cursor-pointer items-center font-medium text-white underline underline-offset-2 opacity-90 hover:opacity-100"
333
+ >
334
+ {toast.actionLabel}
335
+ </button>
336
+ ) : null}
337
+ </div>
338
+ <button
339
+ type="button"
340
+ onClick={() => dismiss(toast.id, 'fade')}
341
+ className="cursor-pointer shrink-0 rounded p-1 opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-current/40 focus-visible:outline-none"
342
+ aria-label="Fermer la notification"
343
+ >
344
+ <X className="h-3.5 w-3.5" />
345
+ </button>
346
+ </div>
347
+ </motion.div>
348
+ );
349
+ })}
350
+ </AnimatePresence>
163
351
  </div>
164
352
  </ToastContext.Provider>
165
353
  );
@@ -0,0 +1,53 @@
1
+ 'use client';
2
+
3
+ import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
4
+ import {
5
+ type DashboardTheme,
6
+ DASHBOARD_THEMES,
7
+ DEFAULT_THEME_KEY,
8
+ getThemeByKey,
9
+ } from '@/lib/dashboard-themes';
10
+
11
+ interface DashboardThemeContextType {
12
+ theme: DashboardTheme;
13
+ setThemeKey: (key: string) => void;
14
+ themes: DashboardTheme[];
15
+ }
16
+
17
+ const DashboardThemeContext = createContext<DashboardThemeContextType | undefined>(undefined);
18
+
19
+ const STORAGE_KEY = 'dashboard_theme';
20
+
21
+ export function DashboardThemeProvider({ children }: Readonly<{ children: ReactNode }>) {
22
+ const [themeKey, setThemeKeyState] = useState(DEFAULT_THEME_KEY);
23
+
24
+ useEffect(() => {
25
+ const stored = localStorage.getItem(STORAGE_KEY);
26
+ if (stored && DASHBOARD_THEMES.some((t) => t.key === stored)) {
27
+ setThemeKeyState(stored);
28
+ }
29
+ }, []);
30
+
31
+ const setThemeKey = (key: string) => {
32
+ setThemeKeyState(key);
33
+ localStorage.setItem(STORAGE_KEY, key);
34
+ };
35
+
36
+ const theme = getThemeByKey(themeKey);
37
+
38
+ const value = useMemo(
39
+ () => ({ theme, setThemeKey, themes: DASHBOARD_THEMES }),
40
+
41
+ [theme],
42
+ );
43
+
44
+ return <DashboardThemeContext.Provider value={value}>{children}</DashboardThemeContext.Provider>;
45
+ }
46
+
47
+ export function useDashboardTheme() {
48
+ const context = useContext(DashboardThemeContext);
49
+ if (!context) {
50
+ throw new Error('useDashboardTheme doit être utilisé dans un DashboardThemeProvider');
51
+ }
52
+ return context;
53
+ }
@@ -13,43 +13,48 @@ interface SidebarContextType {
13
13
 
14
14
  const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
15
15
 
16
+ /** Valeurs initiales fixes = même rendu SSR et 1er rendu client (évite mismatch d’hydratation). */
17
+ const SIDEBAR_PINNED_DEFAULT = true;
18
+ const IS_MOBILE_DEFAULT = false;
19
+
16
20
  export function SidebarProvider({ children }: { children: ReactNode }) {
17
- const [isPinned, setIsPinnedState] = useState(() =>
18
- safeLocalStorageGet<boolean>('sidebar-pinned', true),
19
- );
20
- const [isMobile, setIsMobile] = useState(() =>
21
- typeof globalThis.window === 'undefined' ? false : globalThis.window.innerWidth < 1024,
22
- );
21
+ const [isPinned, setIsPinnedState] = useState(SIDEBAR_PINNED_DEFAULT);
22
+ const [isMobile, setIsMobile] = useState(IS_MOBILE_DEFAULT);
23
23
  const isCollapsed = isMobile ? false : !isPinned;
24
24
 
25
- // Sauvegarder la préférence dans localStorage et adapter le collapse
26
- useEffect(() => {
27
- if (typeof globalThis.window === 'undefined') return;
28
- safeLocalStorageSet('sidebar-pinned', isPinned);
29
- }, [isPinned]);
30
-
31
- // Écouter les changements de taille d'écran
25
+ // Après hydratation : lire localStorage + viewport (pas dans useState pour coller au HTML serveur)
32
26
  useEffect(() => {
33
- if (typeof globalThis.window === 'undefined') return;
27
+ setIsPinnedState(safeLocalStorageGet<boolean>('sidebar-pinned', SIDEBAR_PINNED_DEFAULT));
34
28
 
35
29
  const handleResize = () => {
36
30
  setIsMobile(globalThis.window.innerWidth < 1024);
37
31
  };
38
-
32
+ handleResize();
39
33
  globalThis.window.addEventListener('resize', handleResize);
40
34
  return () => globalThis.window.removeEventListener('resize', handleResize);
41
35
  }, []);
42
36
 
37
+ const persistPinned = (pinned: boolean) => {
38
+ safeLocalStorageSet('sidebar-pinned', pinned);
39
+ };
40
+
43
41
  const setIsPinned = (pinned: boolean) => {
44
42
  setIsPinnedState(pinned);
43
+ persistPinned(pinned);
45
44
  };
46
45
 
47
46
  const setIsCollapsed = (collapsed: boolean) => {
48
- setIsPinnedState(!collapsed);
47
+ const pinned = !collapsed;
48
+ setIsPinnedState(pinned);
49
+ persistPinned(pinned);
49
50
  };
50
51
 
51
52
  const togglePin = () => {
52
- setIsPinnedState(!isPinned);
53
+ setIsPinnedState((prev) => {
54
+ const next = !prev;
55
+ persistPinned(next);
56
+ return next;
57
+ });
53
58
  };
54
59
 
55
60
  return (