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
@@ -8,11 +8,18 @@ import Link from 'next/link';
8
8
  import { cn } from '@/lib/utils';
9
9
  import { useMobileMenuContext } from '@/contexts/mobile-menu-context';
10
10
  import { GlobalSearch } from '@/components/global-search';
11
- import { safeLocalStorageGet, safeLocalStorageSet } from '@/lib/local-storage';
11
+ import {
12
+ REMINDERS_CLEAR_UNDO_WINDOW_MS,
13
+ REMINDERS_POLL_INTERVAL_MS,
14
+ REMINDERS_REFRESH_EVENT,
15
+ requestRemindersRefresh,
16
+ } from '@/lib/reminder-state';
17
+ import { useAppToast } from '@/contexts/app-toast-context';
12
18
 
13
19
  interface Reminder {
14
20
  id: string;
15
21
  taskId: string;
22
+ kind: 'due' | 'reminder';
16
23
  type: string;
17
24
  title: string | null;
18
25
  description: string;
@@ -20,6 +27,9 @@ interface Reminder {
20
27
  scheduledAt: string;
21
28
  reminderTime: string;
22
29
  reminderMinutesBefore: number | null;
30
+ isRead: boolean;
31
+ isDismissed: boolean;
32
+ isClearedByCutoff: boolean;
23
33
  contact: {
24
34
  id: string;
25
35
  firstName: string | null;
@@ -42,6 +52,30 @@ const PRIORITY_COLORS: Record<string, string> = {
42
52
  URGENT: 'bg-red-100 text-red-700',
43
53
  };
44
54
 
55
+ const REMINDER_KIND_BADGE: Record<Reminder['kind'], { label: string; className: string }> = {
56
+ due: {
57
+ label: 'À faire maintenant',
58
+ className: 'bg-amber-100 text-amber-900 ring-1 ring-amber-200/80',
59
+ },
60
+ reminder: {
61
+ label: 'Rappel',
62
+ className: 'bg-sky-100 text-sky-900 ring-1 ring-sky-200/80',
63
+ },
64
+ };
65
+
66
+ function priorityLabel(priority: string): string {
67
+ switch (priority) {
68
+ case 'URGENT':
69
+ return 'Urgente';
70
+ case 'HIGH':
71
+ return 'Haute';
72
+ case 'MEDIUM':
73
+ return 'Moyenne';
74
+ default:
75
+ return 'Faible';
76
+ }
77
+ }
78
+
45
79
  function formatDateTime(dateString: string) {
46
80
  const date = new Date(dateString);
47
81
  return {
@@ -58,12 +92,12 @@ function formatDateTime(dateString: string) {
58
92
 
59
93
  export function Header() {
60
94
  const { data: session } = useSession();
95
+ const { persistent: showPersistentToast } = useAppToast();
61
96
  const router = useRouter();
62
97
  const { toggle: toggleMobileMenu } = useMobileMenuContext();
63
98
  const [showRemindersDropdown, setShowRemindersDropdown] = useState(false);
64
99
  const [showUserDropdown, setShowUserDropdown] = useState(false);
65
100
  const [reminders, setReminders] = useState<Reminder[]>([]);
66
- const [readReminders, setReadReminders] = useState<Set<string>>(new Set());
67
101
  const [loading, setLoading] = useState(false);
68
102
  const remindersRef = useRef<HTMLDivElement>(null);
69
103
  const userRef = useRef<HTMLDivElement>(null);
@@ -72,26 +106,15 @@ export function Header() {
72
106
  const userEmail = session?.user?.email || '';
73
107
  const userInitial = userName?.[0]?.toUpperCase() || 'U';
74
108
 
75
- // Charger les rappels lus depuis localStorage
76
- useEffect(() => {
77
- const stored = safeLocalStorageGet<string[]>('read-reminders', []);
78
- setReadReminders(new Set(stored));
79
- }, []);
80
-
81
- // Sauvegarder les rappels lus dans localStorage
82
- useEffect(() => {
83
- if (readReminders.size > 0) {
84
- safeLocalStorageSet('read-reminders', Array.from(readReminders));
85
- }
86
- }, [readReminders]);
87
-
88
- // Charger les rappels
109
+ // Charger les rappels (polling + retour sur l’onglet + événement explicite)
89
110
  useEffect(() => {
90
111
  if (!session) return;
91
112
 
92
- const fetchReminders = async () => {
113
+ const fetchReminders = async (opts?: { silent?: boolean }) => {
93
114
  try {
94
- setLoading(true);
115
+ if (!opts?.silent) {
116
+ setLoading(true);
117
+ }
95
118
  const response = await fetch('/api/reminders');
96
119
  if (response.ok) {
97
120
  const data = await response.json();
@@ -100,13 +123,29 @@ export function Header() {
100
123
  } catch (error) {
101
124
  console.error('Erreur lors du chargement des rappels:', error);
102
125
  } finally {
103
- setLoading(false);
126
+ if (!opts?.silent) {
127
+ setLoading(false);
128
+ }
129
+ }
130
+ };
131
+
132
+ void fetchReminders();
133
+ const interval = setInterval(() => void fetchReminders({ silent: true }), REMINDERS_POLL_INTERVAL_MS);
134
+
135
+ const onVisible = () => {
136
+ if (document.visibilityState === 'visible') {
137
+ void fetchReminders({ silent: true });
104
138
  }
105
139
  };
140
+ const onRefresh = () => void fetchReminders({ silent: true });
106
141
 
107
- fetchReminders();
108
- const interval = setInterval(fetchReminders, 60 * 1000); // Rafraîchir toutes les minutes
109
- return () => clearInterval(interval);
142
+ document.addEventListener('visibilitychange', onVisible);
143
+ globalThis.addEventListener(REMINDERS_REFRESH_EVENT, onRefresh);
144
+ return () => {
145
+ clearInterval(interval);
146
+ document.removeEventListener('visibilitychange', onVisible);
147
+ globalThis.removeEventListener(REMINDERS_REFRESH_EVENT, onRefresh);
148
+ };
110
149
  }, [session]);
111
150
 
112
151
  // Fermer les dropdowns en cliquant à l'extérieur
@@ -124,15 +163,53 @@ export function Header() {
124
163
  return () => document.removeEventListener('mousedown', handleClickOutside);
125
164
  }, []);
126
165
 
127
- const unreadCount = reminders.filter((r) => !readReminders.has(r.id)).length;
166
+ const unreadCount = reminders.filter((r) => !r.isRead && !r.isDismissed).length;
128
167
 
129
- const handleMarkAsRead = (reminderId: string) => {
130
- setReadReminders((prev) => new Set([...prev, reminderId]));
168
+ const handleMarkAsRead = async (reminderId: string) => {
169
+ setReminders((prev) =>
170
+ prev.map((reminder) =>
171
+ reminder.id === reminderId ? { ...reminder, isRead: true } : reminder,
172
+ ),
173
+ );
174
+ globalThis.dispatchEvent(new CustomEvent('reminders:read', { detail: { reminderId } }));
175
+ try {
176
+ await fetch('/api/reminders/state', {
177
+ method: 'POST',
178
+ headers: { 'Content-Type': 'application/json' },
179
+ body: JSON.stringify({ reminderId, status: 'READ' }),
180
+ });
181
+ } catch (error) {
182
+ console.error("Erreur lors du marquage d'un rappel comme lu:", error);
183
+ }
131
184
  };
132
185
 
133
- const handleMarkAllAsRead = () => {
134
- const allIds = new Set(reminders.map((r) => r.id));
135
- setReadReminders((prev) => new Set([...prev, ...allIds]));
186
+ const handleClearReminders = async () => {
187
+ try {
188
+ const response = await fetch('/api/reminders/clear', { method: 'POST' });
189
+ const data = (await response.json().catch(() => ({}))) as {
190
+ degraded?: boolean;
191
+ undoUntil?: string;
192
+ };
193
+ if (!response.ok) {
194
+ throw new Error('Impossible de vider les rappels.');
195
+ }
196
+ setReminders([]);
197
+ globalThis.dispatchEvent(new CustomEvent('reminders:cleared'));
198
+ if (data.degraded !== true && typeof data.undoUntil === 'string') {
199
+ showPersistentToast('info', 'Rappels vidés.', {
200
+ actionLabel: 'Annuler',
201
+ actionOnClick: async () => {
202
+ const undoRes = await fetch('/api/reminders/clear/undo', { method: 'POST' });
203
+ if (undoRes.ok) {
204
+ requestRemindersRefresh();
205
+ }
206
+ },
207
+ autoDismissMs: REMINDERS_CLEAR_UNDO_WINDOW_MS,
208
+ });
209
+ }
210
+ } catch (error) {
211
+ console.error('Erreur lors du vidage des rappels:', error);
212
+ }
136
213
  };
137
214
 
138
215
  const handleSignOut = async () => {
@@ -141,7 +218,7 @@ export function Header() {
141
218
  };
142
219
 
143
220
  return (
144
- <header className="sticky top-0 z-40 border-b border-border bg-background/95 px-4 py-3 backdrop-blur-sm sm:px-6 lg:px-8">
221
+ <header className="border-border bg-background/95 sticky top-0 z-40 border-b px-4 py-3 backdrop-blur-sm sm:px-6 lg:px-8">
145
222
  <div className="flex items-center gap-2 sm:gap-4">
146
223
  {/* Left: Logo + Greeting */}
147
224
  <div className="flex shrink-0 items-center gap-2 sm:gap-3">
@@ -149,12 +226,12 @@ export function Header() {
149
226
  {/* Bouton burger pour mobile */}
150
227
  <button
151
228
  onClick={toggleMobileMenu}
152
- className="cursor-pointer rounded-lg p-2 text-foreground/80 transition-colors duration-200 hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none lg:hidden"
229
+ className="text-foreground/80 hover:bg-muted focus-visible:ring-primary cursor-pointer rounded-lg p-2 transition-colors duration-200 focus-visible:ring-2 focus-visible:outline-none lg:hidden"
153
230
  aria-label="Ouvrir ou fermer le menu"
154
231
  >
155
232
  <Menu className="h-5 w-5" />
156
233
  </button>
157
- <span className="text-base font-bold text-foreground sm:text-lg">Gold Blessing</span>
234
+ <span className="text-foreground text-base font-bold sm:text-lg">CRM Template</span>
158
235
  </div>
159
236
  </div>
160
237
 
@@ -169,12 +246,21 @@ export function Header() {
169
246
  <div className="relative" ref={remindersRef}>
170
247
  <button
171
248
  onClick={() => setShowRemindersDropdown(!showRemindersDropdown)}
172
- className="relative cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors duration-200 hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none"
249
+ className="text-muted-foreground hover:bg-muted focus-visible:ring-primary relative cursor-pointer rounded-lg p-2 transition-colors duration-200 focus-visible:ring-2 focus-visible:outline-none"
173
250
  aria-label="Notifications"
174
251
  >
175
- <Bell className="h-5 w-5" />
252
+ <span
253
+ className={cn(
254
+ unreadCount > 0 && !showRemindersDropdown && 'ui-bell-notify',
255
+ )}
256
+ >
257
+ <Bell className="h-5 w-5" />
258
+ </span>
176
259
  {unreadCount > 0 && (
177
- <span className="absolute top-1 right-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-semibold text-primary-foreground">
260
+ <span
261
+ key={unreadCount}
262
+ className="bg-primary text-primary-foreground ui-count-pop absolute top-1 right-1 flex h-4 w-4 items-center justify-center rounded-full text-[10px] font-semibold"
263
+ >
178
264
  {unreadCount > 9 ? '9+' : unreadCount}
179
265
  </span>
180
266
  )}
@@ -182,67 +268,85 @@ export function Header() {
182
268
 
183
269
  {/* Dropdown des rappels */}
184
270
  {showRemindersDropdown && (
185
- <div className="absolute right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] rounded-xl border border-border bg-popover shadow-(--shadow-dropdown)">
186
- <div className="border-b border-border px-4 py-3">
271
+ <div className="border-border bg-popover ui-dropdown-enter absolute right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] rounded-xl border shadow-(--shadow-dropdown)">
272
+ <div className="border-border border-b px-4 py-3">
187
273
  <div className="flex items-center justify-between">
188
- <h3 className="text-sm font-semibold text-popover-foreground">Rappels</h3>
189
- {unreadCount > 0 && (
274
+ <h3 className="text-popover-foreground text-sm font-semibold">Rappels</h3>
275
+ {reminders.length > 0 && (
190
276
  <button
191
- onClick={handleMarkAllAsRead}
192
- className="cursor-pointer text-xs text-primary hover:text-primary/80 focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none"
277
+ onClick={handleClearReminders}
278
+ className="text-primary hover:text-primary/80 focus-visible:ring-primary cursor-pointer text-xs focus-visible:ring-2 focus-visible:outline-none"
193
279
  >
194
- Tout marquer comme lu
280
+ Vider les rappels
195
281
  </button>
196
282
  )}
197
283
  </div>
198
284
  </div>
199
285
  <div className="max-h-96 overflow-y-auto">
200
- {loading ? (
201
- <div className="px-4 py-8 text-center text-sm text-muted-foreground">Chargement...</div>
202
- ) : reminders.length === 0 ? (
203
- <div className="px-4 py-8 text-center text-sm text-muted-foreground">Aucun rappel</div>
204
- ) : (
205
- <div className="divide-y divide-border">
286
+ {loading && (
287
+ <div className="text-muted-foreground px-4 py-8 text-center text-sm">
288
+ Chargement...
289
+ </div>
290
+ )}
291
+ {!loading && reminders.length === 0 && (
292
+ <div className="text-muted-foreground px-4 py-8 text-center text-sm">
293
+ Aucun rappel
294
+ </div>
295
+ )}
296
+ {!loading && reminders.length > 0 && (
297
+ <div className="divide-border divide-y">
206
298
  {reminders.map((reminder) => {
207
- const isRead = readReminders.has(reminder.id);
208
- const { date, time } = formatDateTime(reminder.scheduledAt);
209
- const contactName = reminder.contact
210
- ? `${reminder.contact.firstName || ''} ${reminder.contact.lastName || ''}`.trim() ||
211
- 'Contact sans nom'
212
- : null;
299
+ const isRead = reminder.isRead || reminder.isDismissed;
300
+ const { date, time } = formatDateTime(reminder.reminderTime);
301
+ const kindBadge = REMINDER_KIND_BADGE[reminder.kind] ?? REMINDER_KIND_BADGE.reminder;
302
+ let contactName: string | null = null;
303
+ if (reminder.contact) {
304
+ const full = `${reminder.contact.firstName || ''} ${reminder.contact.lastName || ''}`.trim();
305
+ contactName = full.length > 0 ? full : 'Contact sans nom';
306
+ }
213
307
 
214
308
  return (
215
309
  <button
216
310
  type="button"
217
311
  key={reminder.id}
218
312
  className={cn(
219
- 'w-full px-4 py-3 text-left transition-colors duration-200 hover:bg-accent',
220
- !isRead && 'bg-accent/70',
313
+ 'hover:bg-accent w-full px-4 py-3 text-left transition-colors duration-200',
314
+ isRead ? undefined : 'bg-accent/70',
221
315
  )}
222
- onClick={() => handleMarkAsRead(reminder.id)}
316
+ onClick={() => void handleMarkAsRead(reminder.id)}
223
317
  >
224
318
  <div className="flex items-start gap-3">
225
319
  <div className="mt-0.5 shrink-0">
226
- <Calendar className="h-4 w-4 text-primary" />
320
+ <Calendar aria-hidden="true" className="text-primary h-4 w-4" />
227
321
  </div>
228
322
  <div className="min-w-0 flex-1">
229
323
  <div className="flex items-start justify-between gap-2">
230
324
  <div className="min-w-0 flex-1">
325
+ <span
326
+ className={cn(
327
+ 'mb-1 inline-flex max-w-full rounded-full px-2 py-0.5 text-[10px] font-semibold tracking-wide uppercase',
328
+ kindBadge.className,
329
+ )}
330
+ >
331
+ {kindBadge.label}
332
+ </span>
231
333
  <p
232
334
  className={cn(
233
335
  'text-sm font-medium',
234
- !isRead ? 'text-foreground' : 'text-muted-foreground',
336
+ isRead ? 'text-muted-foreground' : 'text-foreground',
235
337
  )}
236
338
  >
237
339
  {reminder.title || TASK_TYPE_LABELS[reminder.type] || 'Tâche'}
238
340
  </p>
239
341
  {contactName && (
240
- <p className="mt-0.5 text-xs text-muted-foreground">{contactName}</p>
342
+ <p className="text-muted-foreground mt-0.5 text-xs">
343
+ {contactName}
344
+ </p>
241
345
  )}
242
- <div className="mt-1 flex items-center gap-2">
243
- <span className="text-xs text-muted-foreground">{date}</span>
244
- <span className="text-xs text-muted-foreground/70">•</span>
245
- <span className="text-xs text-muted-foreground">{time}</span>
346
+ <div className="mt-1 flex flex-wrap items-center gap-2">
347
+ <span className="text-muted-foreground text-xs">{date}</span>
348
+ <span className="text-muted-foreground/70 text-xs">•</span>
349
+ <span className="text-muted-foreground text-xs">{time}</span>
246
350
  <span
247
351
  className={cn(
248
352
  'rounded-full px-1.5 py-0.5 text-[10px] font-medium',
@@ -250,25 +354,19 @@ export function Header() {
250
354
  PRIORITY_COLORS.MEDIUM,
251
355
  )}
252
356
  >
253
- {reminder.priority === 'URGENT'
254
- ? 'Urgente'
255
- : reminder.priority === 'HIGH'
256
- ? 'Haute'
257
- : reminder.priority === 'MEDIUM'
258
- ? 'Moyenne'
259
- : 'Faible'}
357
+ {priorityLabel(reminder.priority)}
260
358
  </span>
261
359
  </div>
262
360
  </div>
263
361
  {!isRead && (
264
- <div className="h-2 w-2 shrink-0 rounded-full bg-primary" />
362
+ <div className="bg-primary h-2 w-2 shrink-0 rounded-full" />
265
363
  )}
266
364
  </div>
267
365
  {reminder.contact && (
268
366
  <Link
269
367
  href={`/contacts/${reminder.contact.id}`}
270
368
  onClick={(e) => e.stopPropagation()}
271
- className="mt-2 inline-block text-xs font-medium text-primary hover:text-primary/80"
369
+ className="text-primary hover:text-primary/80 mt-2 inline-block text-xs font-medium"
272
370
  >
273
371
  Voir le contact
274
372
  </Link>
@@ -289,26 +387,26 @@ export function Header() {
289
387
  <div className="relative" ref={userRef}>
290
388
  <button
291
389
  onClick={() => setShowUserDropdown(!showUserDropdown)}
292
- className="flex cursor-pointer items-center gap-1.5 rounded-md transition-colors duration-200 hover:bg-accent focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none sm:gap-2"
390
+ className="hover:bg-accent focus-visible:ring-primary flex cursor-pointer items-center gap-1.5 rounded-md transition-colors duration-200 focus-visible:ring-2 focus-visible:outline-none sm:gap-2"
293
391
  aria-label="Menu utilisateur"
294
392
  >
295
- <div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/15 text-xs font-semibold text-primary sm:h-9 sm:w-9 sm:text-sm">
393
+ <div className="bg-primary/15 text-primary flex h-8 w-8 items-center justify-center rounded-full text-xs font-semibold sm:h-9 sm:w-9 sm:text-sm">
296
394
  {userInitial}
297
395
  </div>
298
- <ChevronDown className="hidden h-4 w-4 text-muted-foreground transition-colors hover:text-foreground sm:block" />
396
+ <ChevronDown className="text-muted-foreground hover:text-foreground hidden h-4 w-4 transition-colors sm:block" />
299
397
  </button>
300
398
 
301
399
  {/* Dropdown utilisateur */}
302
400
  {showUserDropdown && (
303
- <div className="absolute right-0 mt-2 w-56 rounded-xl border border-border bg-popover shadow-(--shadow-dropdown)">
304
- <div className="border-b border-border px-4 py-3">
305
- <p className="text-sm font-medium text-popover-foreground">{userName}</p>
306
- <p className="mt-0.5 text-xs text-muted-foreground">{userEmail}</p>
401
+ <div className="border-border bg-popover ui-dropdown-enter absolute right-0 mt-2 w-56 rounded-xl border shadow-(--shadow-dropdown)">
402
+ <div className="border-border border-b px-4 py-3">
403
+ <p className="text-popover-foreground text-sm font-medium">{userName}</p>
404
+ <p className="text-muted-foreground mt-0.5 text-xs">{userEmail}</p>
307
405
  </div>
308
406
  <div className="py-1">
309
407
  <button
310
408
  onClick={handleSignOut}
311
- className="flex w-full cursor-pointer items-center gap-2 px-4 py-2 text-sm text-popover-foreground transition-colors duration-200 hover:bg-accent focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none"
409
+ className="text-popover-foreground hover:bg-accent focus-visible:ring-primary flex w-full cursor-pointer items-center gap-2 px-4 py-2 text-sm transition-colors duration-200 focus-visible:ring-2 focus-visible:outline-none"
312
410
  >
313
411
  <LogOut className="h-4 w-4" />
314
412
  <span>Déconnexion</span>
@@ -0,0 +1,58 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import { signOut } from '@/lib/auth-client';
6
+
7
+ const POLL_MS = 25_000;
8
+
9
+ async function fetchActiveStatus(): Promise<boolean | null> {
10
+ try {
11
+ const res = await fetch('/api/auth/check-active', { credentials: 'include' });
12
+ if (!res.ok) return null;
13
+ const data = (await res.json()) as { active?: boolean };
14
+ if (typeof data.active !== 'boolean') return null;
15
+ return data.active;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Déconnecte et redirige vers /signin si le compte courant est inactif ou sans session valide,
23
+ * pour éviter de rester sur le shell du CRM avec une session révoquée côté serveur.
24
+ */
25
+ export function InactiveAccountGuard() {
26
+ const router = useRouter();
27
+ const signingOutRef = useRef(false);
28
+
29
+ useEffect(() => {
30
+ const run = async () => {
31
+ if (signingOutRef.current) return;
32
+ const active = await fetchActiveStatus();
33
+ if (active === false) {
34
+ signingOutRef.current = true;
35
+ await signOut();
36
+ router.replace('/signin?inactive=1');
37
+ }
38
+ };
39
+
40
+ void run();
41
+
42
+ const interval = setInterval(() => {
43
+ if (document.visibilityState === 'visible') void run();
44
+ }, POLL_MS);
45
+
46
+ const onVisibility = () => {
47
+ if (document.visibilityState === 'visible') void run();
48
+ };
49
+ document.addEventListener('visibilitychange', onVisibility);
50
+
51
+ return () => {
52
+ clearInterval(interval);
53
+ document.removeEventListener('visibilitychange', onVisibility);
54
+ };
55
+ }, [router]);
56
+
57
+ return null;
58
+ }
@@ -0,0 +1,12 @@
1
+ 'use client';
2
+
3
+ import { useIntegrationNotifications } from '@/hooks/useIntegrationNotifications';
4
+
5
+ /**
6
+ * Renders nothing; runs the integration notifications polling hook
7
+ * (toasts disabled to avoid accumulation).
8
+ */
9
+ export function IntegrationNotificationsListener() {
10
+ useIntegrationNotifications();
11
+ return null;
12
+ }
@@ -1,4 +1,4 @@
1
- import DOMPurify from 'isomorphic-dompurify';
1
+ import { sanitizeEmailHtml } from '@/lib/email-html-sanitize';
2
2
 
3
3
  interface InvitationEmailProps {
4
4
  name: string;
@@ -73,7 +73,7 @@ export function InvitationEmailTemplate({ name, invitationUrl, signature }: Invi
73
73
  fontSize: '14px',
74
74
  lineHeight: '1.6',
75
75
  }}
76
- dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(signature) }}
76
+ dangerouslySetInnerHTML={{ __html: sanitizeEmailHtml(signature) }}
77
77
  />
78
78
  )}
79
79
  </div>
@@ -1,4 +1,4 @@
1
- import DOMPurify from 'isomorphic-dompurify';
1
+ import { sanitizeEmailHtml } from '@/lib/email-html-sanitize';
2
2
 
3
3
  interface MeetCancellationEmailTemplateProps {
4
4
  contactName: string;
@@ -101,7 +101,7 @@ export function MeetCancellationEmailTemplate({
101
101
  <strong>Description :</strong>
102
102
  <div
103
103
  style={{ marginTop: '10px' }}
104
- dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(description) }}
104
+ dangerouslySetInnerHTML={{ __html: sanitizeEmailHtml(description) }}
105
105
  />
106
106
  </div>
107
107
  )}
@@ -119,7 +119,7 @@ export function MeetCancellationEmailTemplate({
119
119
  borderTop: '1px solid #ddd',
120
120
  fontSize: '14px',
121
121
  }}
122
- dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(signature) }}
122
+ dangerouslySetInnerHTML={{ __html: sanitizeEmailHtml(signature) }}
123
123
  />
124
124
  )}
125
125
  </div>
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import DOMPurify from 'isomorphic-dompurify';
2
+ import { sanitizeEmailHtml } from '@/lib/email-html-sanitize';
3
3
 
4
4
  interface MeetConfirmationEmailTemplateProps {
5
5
  contactName: string;
@@ -101,7 +101,7 @@ export function MeetConfirmationEmailTemplate({
101
101
  <strong>Description :</strong>
102
102
  <div
103
103
  style={{ marginTop: '10px' }}
104
- dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(description) }}
104
+ dangerouslySetInnerHTML={{ __html: sanitizeEmailHtml(description) }}
105
105
  />
106
106
  </div>
107
107
  )}
@@ -154,7 +154,7 @@ export function MeetConfirmationEmailTemplate({
154
154
  borderTop: '1px solid #ddd',
155
155
  fontSize: '14px',
156
156
  }}
157
- dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(signature) }}
157
+ dangerouslySetInnerHTML={{ __html: sanitizeEmailHtml(signature) }}
158
158
  />
159
159
  )}
160
160
  </div>
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import DOMPurify from 'isomorphic-dompurify';
2
+ import { sanitizeEmailHtml } from '@/lib/email-html-sanitize';
3
3
 
4
4
  interface MeetUpdateEmailTemplateProps {
5
5
  contactName: string;
@@ -161,7 +161,7 @@ export function MeetUpdateEmailTemplate({
161
161
  <strong>Description :</strong>
162
162
  <div
163
163
  style={{ marginTop: '10px' }}
164
- dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(description) }}
164
+ dangerouslySetInnerHTML={{ __html: sanitizeEmailHtml(description) }}
165
165
  />
166
166
  </div>
167
167
  )}
@@ -207,7 +207,7 @@ export function MeetUpdateEmailTemplate({
207
207
  borderTop: '1px solid #ddd',
208
208
  fontSize: '14px',
209
209
  }}
210
- dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(signature) }}
210
+ dangerouslySetInnerHTML={{ __html: sanitizeEmailHtml(signature) }}
211
211
  />
212
212
  )}
213
213
  </div>
@@ -8,21 +8,21 @@ interface PageHeaderProps {
8
8
 
9
9
  export function PageHeader({ title, description, action }: Readonly<PageHeaderProps>) {
10
10
  return (
11
- <div className="border-b border-border bg-background/95 px-4 py-4 backdrop-blur-sm sm:px-6 lg:px-8 lg:py-6">
11
+ <div className="border-border bg-background/95 border-b px-4 py-4 backdrop-blur-sm sm:px-6 lg:px-8 lg:py-6">
12
12
  <div className="flex items-start gap-3">
13
13
  <div className="min-w-0 flex-1">
14
14
  {action ? (
15
15
  <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
16
16
  <div className="min-w-0 flex-1">
17
- <h1 className="text-xl font-bold text-foreground sm:text-2xl">{title}</h1>
18
- {description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
17
+ <h1 className="text-foreground text-xl font-bold sm:text-2xl">{title}</h1>
18
+ {description && <p className="text-muted-foreground mt-1 text-sm">{description}</p>}
19
19
  </div>
20
20
  <div className="shrink-0">{action}</div>
21
21
  </div>
22
22
  ) : (
23
23
  <>
24
- <h1 className="text-xl font-bold text-foreground sm:text-2xl">{title}</h1>
25
- {description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
24
+ <h1 className="text-foreground text-xl font-bold sm:text-2xl">{title}</h1>
25
+ {description && <p className="text-muted-foreground mt-1 text-sm">{description}</p>}
26
26
  </>
27
27
  )}
28
28
  </div>
@@ -20,7 +20,7 @@ function getFirstAccessibleRoute(hasPermission: (p: string) => boolean): string
20
20
  return route.href;
21
21
  }
22
22
  }
23
- return '/contacts';
23
+ return '/dashboard';
24
24
  }
25
25
 
26
26
  interface ProtectedPageProps {
@@ -1,4 +1,4 @@
1
- import DOMPurify from 'isomorphic-dompurify';
1
+ import { sanitizeEmailHtml } from '@/lib/email-html-sanitize';
2
2
 
3
3
  interface ResetPasswordEmailProps {
4
4
  code: string;
@@ -73,7 +73,7 @@ export function ResetPasswordEmailTemplate({ code, signature }: ResetPasswordEma
73
73
  fontSize: '14px',
74
74
  lineHeight: '1.6',
75
75
  }}
76
- dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(signature) }}
76
+ dangerouslySetInnerHTML={{ __html: sanitizeEmailHtml(signature) }}
77
77
  />
78
78
  )}
79
79
  </div>