@consilioweb/payload-support 5.0.0 → 6.0.1

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 (120) hide show
  1. package/README.md +249 -22
  2. package/dist/components/RichTextEditor/index.js +1 -1
  3. package/dist/components/TicketConversation/RewriteDropdown.js +1 -1
  4. package/dist/components/TicketConversation/components/AISummaryPanel.js +3 -0
  5. package/dist/components/TicketConversation/components/ActionPanels.js +13 -8
  6. package/dist/components/TicketConversation/components/ClientHistory.js +1 -0
  7. package/dist/components/TicketConversation/components/CodeBlock.js +1 -0
  8. package/dist/components/TicketConversation/components/CodeBlockInserter.js +1 -1
  9. package/dist/components/TicketConversation/components/QuickActions.js +10 -8
  10. package/dist/components/TicketConversation/components/TicketHeader.js +2 -0
  11. package/dist/components/TicketConversation/components/TimeTrackingPanel.js +15 -9
  12. package/dist/components/TicketConversation/index.d.ts +10 -0
  13. package/dist/components/TicketConversation/index.js +25 -7
  14. package/dist/index.cjs +306 -60
  15. package/dist/index.d.cts +107 -38
  16. package/dist/index.d.ts +107 -38
  17. package/dist/index.js +300 -59
  18. package/dist/styles/BillingView.module.scss +11 -11
  19. package/dist/styles/ChatView.module.scss +7 -7
  20. package/dist/styles/CommandPalette.module.scss +1 -1
  21. package/dist/styles/CrmView.module.scss +9 -9
  22. package/dist/styles/EmailTracking.module.scss +6 -6
  23. package/dist/styles/ImportConversation.module.scss +5 -5
  24. package/dist/styles/Logs.module.scss +5 -5
  25. package/dist/styles/NewTicket.module.scss +2 -2
  26. package/dist/styles/PendingEmails.module.scss +13 -13
  27. package/dist/styles/SupportDashboard.module.scss +24 -8
  28. package/dist/styles/TicketDetail.module.scss +20 -20
  29. package/dist/styles/TicketInbox.module.scss +6 -6
  30. package/dist/styles/TicketingSettings.module.scss +10 -10
  31. package/dist/styles/TimeDashboard.module.scss +6 -6
  32. package/dist/styles/_tokens.scss +3 -1
  33. package/dist/utils/db.js +20 -0
  34. package/dist/utils/readSettings.js +150 -0
  35. package/dist/views/BillingView/client.js +15 -8
  36. package/dist/views/ChatView/client.js +2 -0
  37. package/dist/views/CrmView/client.js +2 -0
  38. package/dist/views/EmailTrackingView/client.js +23 -11
  39. package/dist/views/ImportConversationView/client.js +1 -0
  40. package/dist/views/LogsView/client.js +1 -1
  41. package/dist/views/NewTicketView/client.js +20 -13
  42. package/dist/views/PendingEmailsView/client.js +9 -3
  43. package/dist/views/SupportDashboardView/client.js +17 -19
  44. package/dist/views/TicketDetailView/client.js +23 -11
  45. package/dist/views/TicketInboxView/client.js +52 -26
  46. package/dist/views/TicketingSettingsView/TicketingSettings.module.scss +7 -7
  47. package/dist/views/TicketingSettingsView/client.js +44 -26
  48. package/dist/views/TimeDashboardView/client.js +10 -7
  49. package/dist/views/shared/ErrorBoundary.d.ts +36 -1
  50. package/dist/views/shared/ErrorBoundary.js +59 -28
  51. package/dist/views/shared/adminTokens.d.ts +15 -5
  52. package/dist/views/shared/adminTokens.js +23 -7
  53. package/dist/views/shared/icons.d.ts +49 -0
  54. package/dist/views/shared/icons.js +92 -0
  55. package/dist/views/shared/locales/en.json +15 -1
  56. package/dist/views/shared/locales/fr.json +15 -1
  57. package/package.json +9 -4
  58. package/scripts/copy-subpath-types.mjs +103 -0
  59. package/scripts/uninstall-data.mjs +178 -0
  60. package/scripts/uninstall.mjs +184 -0
  61. package/scripts/verify-dist-imports.mjs +84 -0
  62. package/src/collections/Tickets.ts +7 -0
  63. package/src/collections/TimeEntries.ts +11 -3
  64. package/src/components/RichTextEditor/index.tsx +1 -1
  65. package/src/components/TicketConversation/RewriteDropdown.tsx +1 -1
  66. package/src/components/TicketConversation/components/AISummaryPanel.tsx +3 -0
  67. package/src/components/TicketConversation/components/ActionPanels.tsx +13 -8
  68. package/src/components/TicketConversation/components/ClientHistory.tsx +1 -0
  69. package/src/components/TicketConversation/components/CodeBlock.tsx +1 -0
  70. package/src/components/TicketConversation/components/CodeBlockInserter.tsx +1 -1
  71. package/src/components/TicketConversation/components/QuickActions.tsx +10 -8
  72. package/src/components/TicketConversation/components/TicketHeader.tsx +2 -0
  73. package/src/components/TicketConversation/components/TimeTrackingPanel.tsx +17 -9
  74. package/src/components/TicketConversation/index.tsx +41 -7
  75. package/src/endpoints/delete-account.ts +191 -47
  76. package/src/endpoints/export-data.ts +104 -4
  77. package/src/endpoints/purge-logs.ts +6 -15
  78. package/src/index.ts +2 -0
  79. package/src/plugin.ts +51 -1
  80. package/src/portal/LiveChat.tsx +1 -1
  81. package/src/portal/auth/profile/page.tsx +3 -2
  82. package/src/portal/icons.tsx +91 -0
  83. package/src/styles/BillingView.module.scss +11 -11
  84. package/src/styles/ChatView.module.scss +7 -7
  85. package/src/styles/CommandPalette.module.scss +1 -1
  86. package/src/styles/CrmView.module.scss +9 -9
  87. package/src/styles/EmailTracking.module.scss +6 -6
  88. package/src/styles/ImportConversation.module.scss +5 -5
  89. package/src/styles/Logs.module.scss +5 -5
  90. package/src/styles/NewTicket.module.scss +2 -2
  91. package/src/styles/PendingEmails.module.scss +13 -13
  92. package/src/styles/SupportDashboard.module.scss +24 -8
  93. package/src/styles/TicketDetail.module.scss +20 -20
  94. package/src/styles/TicketInbox.module.scss +6 -6
  95. package/src/styles/TicketingSettings.module.scss +10 -10
  96. package/src/styles/TimeDashboard.module.scss +6 -6
  97. package/src/styles/_tokens.scss +3 -1
  98. package/src/types.ts +20 -0
  99. package/src/utils/retention.ts +111 -0
  100. package/src/utils/slugs.ts +15 -0
  101. package/src/views/BillingView/client.tsx +17 -8
  102. package/src/views/ChatView/client.tsx +2 -0
  103. package/src/views/CrmView/client.tsx +2 -0
  104. package/src/views/EmailTrackingView/client.tsx +18 -7
  105. package/src/views/ImportConversationView/client.tsx +1 -0
  106. package/src/views/LogsView/client.tsx +1 -1
  107. package/src/views/NewTicketView/client.tsx +22 -13
  108. package/src/views/PendingEmailsView/client.tsx +9 -3
  109. package/src/views/SupportDashboardView/client.tsx +11 -5
  110. package/src/views/TicketDetailView/client.tsx +25 -11
  111. package/src/views/TicketInboxView/client.tsx +47 -7
  112. package/src/views/TicketingSettingsView/TicketingSettings.module.scss +7 -7
  113. package/src/views/TicketingSettingsView/client.tsx +201 -144
  114. package/src/views/TimeDashboardView/client.tsx +10 -7
  115. package/src/views/shared/ErrorBoundary.tsx +95 -31
  116. package/src/views/shared/adminTokens.ts +28 -11
  117. package/src/views/shared/icons.tsx +146 -0
  118. package/src/views/shared/locales/en.json +15 -1
  119. package/src/views/shared/locales/fr.json +15 -1
  120. package/src/types/lucide-react.d.ts +0 -42
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * `support-uninstall` — removes this plugin from a host application.
5
+ *
6
+ * Contract, deliberately identical to the other ConsilioWEB plugins:
7
+ * - DRY RUN by default. Nothing is written without `--confirm`.
8
+ * - a detection gate: the destructive step refuses to run unless this project
9
+ * really uses the plugin (a source file references it, or it is a declared
10
+ * dependency). `--force-db` overrides it.
11
+ * - `--keep-data` cleans the code and the preferences but keeps every row.
12
+ *
13
+ * Usage:
14
+ * npx support-uninstall list what would be removed
15
+ * npx support-uninstall --confirm do it
16
+ * npx support-uninstall --confirm --keep-data code only, rows kept
17
+ * npx support-uninstall --confirm --force-db delete rows even if undetected
18
+ * npx support-uninstall --extra-slugs=a,b add renamed collections
19
+ */
20
+
21
+ import fs from 'node:fs'
22
+ import path from 'node:path'
23
+ import { execSync, spawnSync } from 'node:child_process'
24
+ import { fileURLToPath } from 'node:url'
25
+
26
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
27
+
28
+ function readOwnPackageName() {
29
+ try {
30
+ const pkg = JSON.parse(fs.readFileSync(path.join(HERE, '..', 'package.json'), 'utf-8'))
31
+ if (typeof pkg.name === 'string' && pkg.name) return pkg.name
32
+ } catch {
33
+ // fall through
34
+ }
35
+ return '@consilioweb/payload-support'
36
+ }
37
+
38
+ const PACKAGE_NAME = readOwnPackageName()
39
+
40
+ function detectPackageManager(dir) {
41
+ if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml'))) return 'pnpm'
42
+ if (fs.existsSync(path.join(dir, 'yarn.lock'))) return 'yarn'
43
+ if (fs.existsSync(path.join(dir, 'bun.lockb')) || fs.existsSync(path.join(dir, 'bun.lock'))) return 'bun'
44
+ return 'npm'
45
+ }
46
+
47
+ function findSourceFiles(dir, acc = []) {
48
+ if (!fs.existsSync(dir)) return acc
49
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
50
+ const full = path.join(dir, entry.name)
51
+ if (entry.isDirectory()) {
52
+ if (entry.name === 'node_modules' || entry.name === '.next' || entry.name.startsWith('.')) continue
53
+ findSourceFiles(full, acc)
54
+ } else if (/\.(?:ts|tsx|js|jsx|mjs|cjs)$/.test(entry.name)) {
55
+ acc.push(full)
56
+ }
57
+ }
58
+ return acc
59
+ }
60
+
61
+ function isDeclaredDependency(projectDir) {
62
+ try {
63
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectDir, 'package.json'), 'utf-8'))
64
+ return Boolean(
65
+ pkg.dependencies?.[PACKAGE_NAME]
66
+ || pkg.devDependencies?.[PACKAGE_NAME]
67
+ || pkg.peerDependencies?.[PACKAGE_NAME]
68
+ || pkg.optionalDependencies?.[PACKAGE_NAME],
69
+ )
70
+ } catch {
71
+ return false
72
+ }
73
+ }
74
+
75
+ function run(cmd, cwd) {
76
+ console.log(` $ ${cmd}`)
77
+ try {
78
+ execSync(cmd, { cwd, stdio: 'inherit' })
79
+ return true
80
+ } catch {
81
+ return false
82
+ }
83
+ }
84
+
85
+ function main() {
86
+ const projectDir = process.env.INIT_CWD || process.cwd()
87
+ const argv = process.argv.slice(2)
88
+ const confirm = argv.includes('--confirm')
89
+ const keepData = argv.includes('--keep-data')
90
+ const forceDb = argv.includes('--force-db')
91
+ const extraSlugs = argv.find((a) => a.startsWith('--extra-slugs='))
92
+ const pm = detectPackageManager(projectDir)
93
+
94
+ console.log('')
95
+ console.log(` ${PACKAGE_NAME} — uninstall`)
96
+ console.log(' ─────────────────────────────────────────────')
97
+ console.log(` Project: ${projectDir}`)
98
+ console.log(` Package manager: ${pm}`)
99
+ console.log(` Mode: ${confirm ? 'APPLY' : 'DRY RUN — nothing will be written'}`)
100
+ if (keepData) console.log(' --keep-data: rows will be kept')
101
+ console.log('')
102
+
103
+ // ── 1. Where is the plugin referenced? ───────────────────
104
+ const sourceFiles = findSourceFiles(path.join(projectDir, 'src'))
105
+ const referencing = sourceFiles.filter((file) => {
106
+ try {
107
+ return fs.readFileSync(file, 'utf-8').includes(PACKAGE_NAME)
108
+ } catch {
109
+ return false
110
+ }
111
+ })
112
+
113
+ console.log(' [1/3] Source references')
114
+ if (referencing.length === 0) {
115
+ console.log(' none found under src/')
116
+ } else {
117
+ for (const file of referencing) console.log(` ${path.relative(projectDir, file)}`)
118
+ console.log('')
119
+ console.log(' Remove the `supportPlugin(...)` call and its import yourself.')
120
+ console.log(' This script never rewrites your source: the plugin call carries')
121
+ console.log(' your configuration, and a regex that guesses where it ends is how')
122
+ console.log(' a config file gets silently corrupted.')
123
+ }
124
+ console.log('')
125
+
126
+ const declared = isDeclaredDependency(projectDir)
127
+ const detected = referencing.length > 0 || declared || forceDb
128
+
129
+ // ── 2. Data ──────────────────────────────────────────────
130
+ console.log(' [2/3] Data')
131
+ if (!detected) {
132
+ console.log(` ${PACKAGE_NAME} was not found in this project`)
133
+ console.log(' (no source reference, not in package.json dependencies).')
134
+ console.log(' Skipping the data step. Force it with --force-db.')
135
+ console.log('')
136
+ } else {
137
+ // The Payload local API needs the host's TypeScript config loaded, which is
138
+ // exactly what `payload run` sets up. Spawning it beats reimplementing a
139
+ // TypeScript loader here.
140
+ const dataScript = path.join(HERE, 'uninstall-data.mjs')
141
+ const args = ['payload', 'run', dataScript]
142
+ if (confirm) args.push('--confirm')
143
+ if (keepData) args.push('--keep-data')
144
+ if (extraSlugs) args.push(extraSlugs)
145
+
146
+ const result = spawnSync('npx', args, { cwd: projectDir, stdio: 'inherit' })
147
+ if (result.status !== 0) {
148
+ console.log('')
149
+ console.log(' Could not run the data step through `npx payload run`.')
150
+ console.log(' Run it by hand from your project root:')
151
+ console.log(` npx payload run ${dataScript} ${confirm ? '--confirm' : ''}`)
152
+ console.log('')
153
+ }
154
+ }
155
+
156
+ // ── 3. The package ───────────────────────────────────────
157
+ console.log(' [3/3] Package')
158
+ if (!confirm) {
159
+ const removeCmd = pm === 'npm' ? 'npm uninstall' : `${pm} remove`
160
+ console.log(` would run: ${removeCmd} ${PACKAGE_NAME}`)
161
+ console.log(` would run: ${pm === 'npm' ? 'npx' : pm} payload generate:importmap`)
162
+ console.log('')
163
+ console.log(' Dry run finished. Nothing was changed. Re-run with --confirm.')
164
+ console.log('')
165
+ return
166
+ }
167
+
168
+ if (referencing.length > 0) {
169
+ console.log(' Skipped: the plugin is still imported by the files listed above.')
170
+ console.log(' Remove those references first, then re-run.')
171
+ console.log('')
172
+ return
173
+ }
174
+
175
+ const removeCmd = pm === 'npm' ? 'npm uninstall' : `${pm} remove`
176
+ run(`${removeCmd} ${PACKAGE_NAME}`, projectDir)
177
+ run(`${pm === 'npm' ? 'npx' : pm} payload generate:importmap`, projectDir)
178
+
179
+ console.log('')
180
+ console.log(' Done.')
181
+ console.log('')
182
+ }
183
+
184
+ main()
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Every relative import in `dist/` must resolve to a file that is actually there.
4
+ *
5
+ * This catches one specific, silent failure mode of a multi-pass tsup build.
6
+ * The `bundle: false` pass preserves the source tree and rewrites nothing, so a
7
+ * relative import survives into the emitted file. If the target module is not
8
+ * itself in that pass's `entry` list, it is never emitted as a standalone file —
9
+ * it only exists inlined inside the bundled `dist/index.js`. The import then
10
+ * points at nothing.
11
+ *
12
+ * Nothing in the normal toolchain sees it. `tsc` type-checks the SOURCE tree,
13
+ * where the module exists. Vitest imports from `src/`, same thing. The build
14
+ * itself succeeds — tsup has no reason to object. The failure surfaces only when
15
+ * the HOST application's bundler resolves the published package, which is to say
16
+ * after publication, in someone else's build.
17
+ *
18
+ * `import type` is invisible here on purpose: TypeScript erases it, so it leaves
19
+ * no trace in the emitted file and cannot break anything. Only value imports do.
20
+ * That is why the defect hid for two releases in this package — every other
21
+ * importer of the same module used `import type`.
22
+ */
23
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
24
+ import { dirname, join, resolve } from 'node:path'
25
+
26
+ const DIST = resolve(process.cwd(), 'dist')
27
+
28
+ if (!existsSync(DIST)) {
29
+ console.error('verify-dist-imports: dist/ is missing — run the build first.')
30
+ process.exit(1)
31
+ }
32
+
33
+ /** Every emitted JavaScript file, at any depth. */
34
+ function walk(dir) {
35
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
36
+ const path = join(dir, entry.name)
37
+ if (entry.isDirectory()) return walk(path)
38
+ return /\.(js|cjs|mjs)$/.test(entry.name) ? [path] : []
39
+ })
40
+ }
41
+
42
+ /**
43
+ * Relative specifiers only. Bare specifiers are the consumer's dependency
44
+ * problem, not ours, and `node:` builtins always resolve.
45
+ */
46
+ const SPECIFIER =
47
+ /(?:^|[\s;{(])(?:import|export)\s+(?:[^'"]*?\sfrom\s+)?['"](\.[^'"]+)['"]|require\(\s*['"](\.[^'"]+)['"]\s*\)|import\(\s*['"](\.[^'"]+)['"]\s*\)/g
48
+
49
+ /** Resolve the way Node and a bundler would: as written, then the usual suffixes. */
50
+ function resolves(fromFile, specifier) {
51
+ const base = resolve(dirname(fromFile), specifier)
52
+ const candidates = [
53
+ base,
54
+ `${base}.js`, `${base}.cjs`, `${base}.mjs`,
55
+ join(base, 'index.js'), join(base, 'index.cjs'), join(base, 'index.mjs'),
56
+ ]
57
+ return candidates.some((c) => existsSync(c) && statSync(c).isFile())
58
+ }
59
+
60
+ const broken = []
61
+ for (const file of walk(DIST)) {
62
+ const source = readFileSync(file, 'utf8')
63
+ for (const match of source.matchAll(SPECIFIER)) {
64
+ const specifier = match[1] || match[2] || match[3]
65
+ if (!specifier || resolves(file, specifier)) continue
66
+ broken.push({ file: file.replace(`${process.cwd()}/`, ''), specifier })
67
+ }
68
+ }
69
+
70
+ if (broken.length === 0) {
71
+ console.log('verify-dist-imports: every relative import in dist/ resolves.')
72
+ process.exit(0)
73
+ }
74
+
75
+ console.error(`verify-dist-imports: ${broken.length} unresolved relative import(s) in dist/.\n`)
76
+ for (const { file, specifier } of broken) {
77
+ console.error(` ${file}\n imports '${specifier}' — not emitted`)
78
+ }
79
+ console.error(
80
+ '\nThe target is almost certainly missing from the `entry` list of the\n' +
81
+ '`bundle: false` pass in tsup.config.ts. Add it there, or move the value\n' +
82
+ 'being imported into a module that pass already emits.',
83
+ )
84
+ process.exit(1)
@@ -525,9 +525,16 @@ function createCascadeDelete(slugs: CollectionSlugs): CollectionBeforeDeleteHook
525
525
  return async ({ id, req }) => {
526
526
  const collections = [slugs.ticketMessages, slugs.ticketActivityLog, slugs.timeEntries, slugs.satisfactionSurveys]
527
527
  for (const slug of collections) {
528
+ // `req` must be forwarded: it carries the ambient transaction. Without
529
+ // it these cascades open their own connection and, on SQLite, deadlock
530
+ // against the transaction that is deleting the ticket — the delete then
531
+ // fails silently (Payload collects per-document errors instead of
532
+ // throwing) and the children survive the parent.
533
+ if (!req.payload.collections?.[slug as never]) continue
528
534
  await dbDelete(req.payload, slug, {
529
535
  where: { ticket: { equals: id } },
530
536
  overrideAccess: true,
537
+ req,
531
538
  })
532
539
  }
533
540
  }
@@ -1,13 +1,19 @@
1
- import type { CollectionConfig, CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
1
+ import type { CollectionConfig, CollectionAfterChangeHook, CollectionAfterDeleteHook, PayloadRequest } from 'payload'
2
2
  import type { Payload } from 'payload'
3
3
  import type { CollectionSlugs } from '../utils/slugs'
4
4
 
5
5
  // ─── Hooks ───────────────────────────────────────────────
6
6
 
7
+ /**
8
+ * `req` is forwarded to every operation so the rollup joins the caller's
9
+ * transaction. Reading or writing outside it would see a stale total and, on
10
+ * SQLite, contend with the very transaction that triggered the hook.
11
+ */
7
12
  async function recalculateTicketTime(
8
13
  payload: Payload,
9
14
  slugs: CollectionSlugs,
10
15
  ticketId: number | string,
16
+ req?: PayloadRequest,
11
17
  ): Promise<void> {
12
18
  // Sum all time entries for this ticket, paginating to avoid loading all at once
13
19
  let totalMinutes = 0
@@ -23,6 +29,7 @@ async function recalculateTicketTime(
23
29
  depth: 0,
24
30
  overrideAccess: true,
25
31
  select: { duration: true },
32
+ req,
26
33
  })
27
34
 
28
35
  for (const entry of entries.docs) {
@@ -38,6 +45,7 @@ async function recalculateTicketTime(
38
45
  id: ticketId,
39
46
  data: { totalTimeMinutes: totalMinutes },
40
47
  overrideAccess: true,
48
+ req,
41
49
  })
42
50
  }
43
51
 
@@ -45,7 +53,7 @@ function createRecalculateTicketTime(slugs: CollectionSlugs): CollectionAfterCha
45
53
  return async ({ doc, req }) => {
46
54
  if (!doc.ticket) return
47
55
  const ticketId = typeof doc.ticket === 'object' ? doc.ticket.id : doc.ticket
48
- await recalculateTicketTime(req.payload, slugs, ticketId)
56
+ await recalculateTicketTime(req.payload, slugs, ticketId, req)
49
57
  }
50
58
  }
51
59
 
@@ -59,7 +67,7 @@ function createRecalculateTicketTimeOnDelete(slugs: CollectionSlugs): Collection
59
67
  if (!doc?.ticket) return
60
68
  const ticketId = typeof doc.ticket === 'object' ? doc.ticket.id : doc.ticket
61
69
  try {
62
- await recalculateTicketTime(req.payload, slugs, ticketId)
70
+ await recalculateTicketTime(req.payload, slugs, ticketId, req)
63
71
  } catch (err) {
64
72
  // The entry is already gone; never fail the delete on the rollup refresh.
65
73
  console.error('[support] Failed to recalculate ticket time after delete:', err)
@@ -287,7 +287,6 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, Props>(function R
287
287
  fontSize: '14px',
288
288
  lineHeight: 1.6,
289
289
  color: '#1f2937',
290
- outline: 'none',
291
290
  overflowY: 'auto',
292
291
  }}
293
292
  />
@@ -300,6 +299,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, Props>(function R
300
299
  type="file"
301
300
  accept="image/*"
302
301
  multiple
302
+ aria-label="Inserer une image"
303
303
  onChange={handleFileChange}
304
304
  style={{ display: 'none' }}
305
305
  />
@@ -76,7 +76,7 @@ export const RewriteDropdown: React.FC<{
76
76
  onMouseLeave={(e) => { (e.target as HTMLElement).style.background = 'transparent' }}
77
77
  >
78
78
  <span style={{ fontSize: 12, fontWeight: 600, color: 'var(--theme-text, #111)' }}>{style.label}</span>
79
- <span style={{ fontSize: 10, color: 'var(--theme-elevation-500, #6b7280)' }}>{style.desc}</span>
79
+ <span style={{ fontSize: 10, color: 'var(--theme-elevation-650, #6b7280)' }}>{style.desc}</span>
80
80
  </button>
81
81
  ))}
82
82
  </div>
@@ -39,6 +39,7 @@ export function AISummaryPanel({
39
39
  return (
40
40
  <div style={{ marginBottom: '14px' }}>
41
41
  <button
42
+ type="button"
42
43
  onClick={() => { setShowAiSummary(!showAiSummary); if (!showAiSummary && !aiSummary) handleAiGenerate() }}
43
44
  style={{
44
45
  ...s.ghostBtn('#17807c', false),
@@ -61,6 +62,7 @@ export function AISummaryPanel({
61
62
  </h4>
62
63
  <div style={{ display: 'flex', gap: '6px' }}>
63
64
  <button
65
+ type="button"
64
66
  onClick={handleAiGenerate}
65
67
  disabled={aiGenerating}
66
68
  style={{ ...s.outlineBtn('#17807c', aiGenerating), fontSize: '11px', padding: '4px 10px' }}
@@ -69,6 +71,7 @@ export function AISummaryPanel({
69
71
  </button>
70
72
  {aiSummary && !aiGenerating && (
71
73
  <button
74
+ type="button"
72
75
  onClick={handleAiSave}
73
76
  disabled={aiSaving || aiSaved}
74
77
  style={{ ...s.btn(aiSaved ? '#16a34a' : '#2563eb', aiSaving), fontSize: '11px', padding: '4px 10px' }}
@@ -26,12 +26,13 @@ export function MergePanel({
26
26
  <div style={{ display: 'flex', gap: '8px', alignItems: 'center', marginBottom: '8px' }}>
27
27
  <input
28
28
  type="text"
29
+ aria-label="Numero du ticket cible"
29
30
  value={mergeTarget}
30
31
  onChange={(e) => { setMergeTarget(e.target.value); setMergeTargetInfo(null); setMergeError('') }}
31
32
  placeholder="TK-0001"
32
33
  style={{ ...s.input, width: '130px' }}
33
34
  />
34
- <button onClick={handleMergeLookup} style={{ ...s.outlineBtn('#ec4899'), fontSize: '12px', padding: '6px 14px' }}>
35
+ <button type="button" onClick={handleMergeLookup} style={{ ...s.outlineBtn('#ec4899'), fontSize: '12px', padding: '6px 14px' }}>
35
36
  Rechercher
36
37
  </button>
37
38
  {mergeError && <span style={{ fontSize: '12px', color: '#be185d', fontWeight: 600 }}>{mergeError}</span>}
@@ -41,7 +42,7 @@ export function MergePanel({
41
42
  <span style={{ fontSize: '13px', fontWeight: 600 }}>
42
43
  {mergeTargetInfo.ticketNumber} — {mergeTargetInfo.subject}
43
44
  </span>
44
- <button onClick={handleMerge} disabled={merging} style={{ ...s.btn('#ec4899', merging), color: '#fff', fontSize: '12px', padding: '6px 14px' }}>
45
+ <button type="button" onClick={handleMerge} disabled={merging} style={{ ...s.btn('#ec4899', merging), color: '#fff', fontSize: '12px', padding: '6px 14px' }}>
45
46
  {merging ? 'Fusion...' : 'Confirmer la fusion'}
46
47
  </button>
47
48
  </div>
@@ -76,6 +77,7 @@ export function ExtMessagePanel({
76
77
  <div style={{ padding: '14px 18px', borderRadius: '8px', backgroundColor: '#eef2ff', border: '1px solid #c7d2fe', marginBottom: '14px' }}>
77
78
  <h4 style={{ fontSize: '13px', fontWeight: 600, marginBottom: '10px', color: '#312e81' }}>Ajouter un message reçu (email, SMS, WhatsApp...)</h4>
78
79
  <textarea
80
+ aria-label="Contenu du message recu"
79
81
  value={extMsgBody}
80
82
  onChange={(e) => setExtMsgBody(e.target.value)}
81
83
  placeholder="Coller le contenu du message reçu..."
@@ -83,17 +85,18 @@ export function ExtMessagePanel({
83
85
  style={{ ...s.input, width: '100%', resize: 'vertical', marginBottom: '10px' }}
84
86
  />
85
87
  <div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap', marginBottom: '10px' }}>
86
- <select value={extMsgAuthor} onChange={(e) => setExtMsgAuthor(e.target.value as 'client' | 'admin')} style={{ ...s.input, fontSize: '12px' }}>
88
+ <select aria-label="Auteur du message recu" value={extMsgAuthor} onChange={(e) => setExtMsgAuthor(e.target.value as 'client' | 'admin')} style={{ ...s.input, fontSize: '12px' }}>
87
89
  <option value="client">Envoyé par le client</option>
88
90
  <option value="admin">Envoyé par le support</option>
89
91
  </select>
90
92
  <input
91
93
  type="datetime-local"
94
+ aria-label="Date du message recu"
92
95
  value={extMsgDate}
93
96
  onChange={(e) => setExtMsgDate(e.target.value)}
94
97
  style={{ ...s.input, fontSize: '12px' }}
95
98
  />
96
- <input ref={extFileInputRef} type="file" multiple onChange={handleExtFileChange} style={{ display: 'none' }} accept="image/*,.pdf,.doc,.docx,.txt,.zip" />
99
+ <input ref={extFileInputRef} type="file" multiple aria-label="Joindre des fichiers au message recu" onChange={handleExtFileChange} style={{ display: 'none' }} accept="image/*,.pdf,.doc,.docx,.txt,.zip" />
97
100
  <button type="button" onClick={() => extFileInputRef.current?.click()} style={{ ...s.ghostBtn('#6b7280'), fontSize: '12px', padding: '6px 12px' }}>
98
101
  + PJ
99
102
  </button>
@@ -108,7 +111,7 @@ export function ExtMessagePanel({
108
111
  ))}
109
112
  </div>
110
113
  )}
111
- <button onClick={handleSendExtMsg} disabled={sendingExtMsg || !extMsgBody.trim()} style={s.btn('#1d2b4d', sendingExtMsg || !extMsgBody.trim())}>
114
+ <button type="button" onClick={handleSendExtMsg} disabled={sendingExtMsg || !extMsgBody.trim()} style={s.btn('#1d2b4d', sendingExtMsg || !extMsgBody.trim())}>
112
115
  {sendingExtMsg ? 'Ajout...' : 'Ajouter (sans notification)'}
113
116
  </button>
114
117
  </div>
@@ -186,6 +189,7 @@ export function ReminderPanel({
186
189
  )}
187
190
 
188
191
  <button
192
+ type="button"
189
193
  onClick={handleSendReminder}
190
194
  disabled={reminderSending || !clientEmail}
191
195
  style={{ ...s.btn(C.orange, reminderSending || !clientEmail), color: '#fff', fontSize: '13px', padding: '8px 18px' }}
@@ -207,11 +211,12 @@ export function SnoozePanel({ snoozeSaving, handleSnooze }: SnoozePanelProps) {
207
211
  <div style={{ padding: '14px 18px', borderRadius: '8px', backgroundColor: '#eef8f7', border: '1px solid #b8dcda', marginBottom: '14px' }}>
208
212
  <h4 style={{ fontSize: '13px', fontWeight: 600, marginBottom: '10px', color: '#5b21b6' }}>Snooze — masquer temporairement</h4>
209
213
  <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
210
- <button onClick={() => handleSnooze(1)} disabled={snoozeSaving} style={{ ...s.outlineBtn('#17807c', snoozeSaving), fontSize: '12px' }}>1 jour</button>
211
- <button onClick={() => handleSnooze(3)} disabled={snoozeSaving} style={{ ...s.outlineBtn('#17807c', snoozeSaving), fontSize: '12px' }}>3 jours</button>
212
- <button onClick={() => handleSnooze(7)} disabled={snoozeSaving} style={{ ...s.outlineBtn('#17807c', snoozeSaving), fontSize: '12px' }}>1 semaine</button>
214
+ <button type="button" onClick={() => handleSnooze(1)} disabled={snoozeSaving} style={{ ...s.outlineBtn('#17807c', snoozeSaving), fontSize: '12px' }}>1 jour</button>
215
+ <button type="button" onClick={() => handleSnooze(3)} disabled={snoozeSaving} style={{ ...s.outlineBtn('#17807c', snoozeSaving), fontSize: '12px' }}>3 jours</button>
216
+ <button type="button" onClick={() => handleSnooze(7)} disabled={snoozeSaving} style={{ ...s.outlineBtn('#17807c', snoozeSaving), fontSize: '12px' }}>1 semaine</button>
213
217
  <input
214
218
  type="datetime-local"
219
+ aria-label="Snoozer jusqu'a une date precise"
215
220
  onChange={(e) => { if (e.target.value) handleSnooze(null, e.target.value) }}
216
221
  style={{ ...s.input, fontSize: '12px' }}
217
222
  />
@@ -96,6 +96,7 @@ export function ClientHistory({
96
96
  {/* Notes */}
97
97
  <h5 style={{ fontSize: '12px', fontWeight: 600, color: C.textSecondary, marginBottom: '8px' }}>Notes internes</h5>
98
98
  <textarea
99
+ aria-label="Notes internes sur le client"
99
100
  value={clientNotes}
100
101
  onChange={(e) => onNotesChange(e.target.value)}
101
102
  rows={3}
@@ -90,6 +90,7 @@ function SingleCodeBlock({ lang, code }: { lang: string; code: string }) {
90
90
  </span>
91
91
  </div>
92
92
  <button
93
+ type="button"
93
94
  onClick={handleCopy}
94
95
  style={{
95
96
  background: 'none',
@@ -109,6 +109,7 @@ export function CodeBlockInserter({ onInsert, className, style }: CodeBlockInser
109
109
  <input
110
110
  ref={inputRef}
111
111
  type="text"
112
+ aria-label="Rechercher un langage"
112
113
  value={filter}
113
114
  onChange={(e) => setFilter(e.target.value)}
114
115
  onKeyDown={(e) => {
@@ -124,7 +125,6 @@ export function CodeBlockInserter({ onInsert, className, style }: CodeBlockInser
124
125
  border: '1px solid #334155',
125
126
  borderRadius: 6,
126
127
  color: '#e2e8f0',
127
- outline: 'none',
128
128
  boxSizing: 'border-box',
129
129
  }}
130
130
  />
@@ -48,6 +48,7 @@ export function QuickActions({
48
48
  <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginBottom: '16px', alignItems: 'center' }}>
49
49
  {statusTransitions.map((a) => (
50
50
  <button
51
+ type="button"
51
52
  key={a.status}
52
53
  onClick={() => onStatusChange(a.status)}
53
54
  disabled={statusUpdating}
@@ -57,22 +58,22 @@ export function QuickActions({
57
58
  </button>
58
59
  ))}
59
60
  {snoozeUntil && new Date(snoozeUntil) > new Date() && (
60
- <button onClick={onCancelSnooze} disabled={snoozeSaving} style={{ ...s.ghostBtn('#17807c', snoozeSaving), fontSize: '12px', padding: '5px 10px' }}>
61
+ <button type="button" onClick={onCancelSnooze} disabled={snoozeSaving} style={{ ...s.ghostBtn('#17807c', snoozeSaving), fontSize: '12px', padding: '5px 10px' }}>
61
62
  Annuler snooze
62
63
  </button>
63
64
  )}
64
65
  <span style={{ borderLeft: `1px solid ${C.border}`, height: '20px', margin: '0 4px' }} />
65
- <button onClick={onToggleMerge} style={s.ghostBtn('#be185d')}>Fusionner</button>
66
- <button onClick={onToggleExtMsg} style={s.ghostBtn('#4f46e5')}>+ Message reçu</button>
67
- <button onClick={onToggleSnooze} style={s.ghostBtn('#17807c')}>Snooze</button>
66
+ <button type="button" onClick={onToggleMerge} style={s.ghostBtn('#be185d')}>Fusionner</button>
67
+ <button type="button" onClick={onToggleExtMsg} style={s.ghostBtn('#4f46e5')}>+ Message reçu</button>
68
+ <button type="button" onClick={onToggleSnooze} style={s.ghostBtn('#17807c')}>Snooze</button>
68
69
  {showReminderButton && (
69
- <button onClick={onToggleReminder} style={s.ghostBtn('#ea580c')}>{'⏰'} Relancer</button>
70
+ <button type="button" onClick={onToggleReminder} style={s.ghostBtn('#ea580c')}>{'⏰'} Relancer</button>
70
71
  )}
71
- <button onClick={onNextTicket} style={s.ghostBtn('#16a34a')}>Ticket suivant</button>
72
+ <button type="button" onClick={onNextTicket} style={s.ghostBtn('#16a34a')}>Ticket suivant</button>
72
73
  {scheduledClose && (
73
74
  <span style={{ display: 'inline-flex', alignItems: 'center', gap: '8px', padding: '5px 10px', borderRadius: '6px', backgroundColor: '#fff7ed', border: '1px solid #fed7aa', fontSize: '12px', color: '#9a3412', fontWeight: 600 }}>
74
75
  {'⏰'} Fermeture auto le {scheduledClose}
75
- <button onClick={onCancelScheduledClose} disabled={cancelingClose} style={{ border: 'none', background: 'none', color: '#b91c1c', cursor: cancelingClose ? 'not-allowed' : 'pointer', fontWeight: 700, fontSize: '12px', textDecoration: 'underline', padding: 0, opacity: cancelingClose ? 0.5 : 1 }}>
76
+ <button type="button" onClick={onCancelScheduledClose} disabled={cancelingClose} style={{ border: 'none', background: 'none', color: '#b91c1c', cursor: cancelingClose ? 'not-allowed' : 'pointer', fontWeight: 700, fontSize: '12px', textDecoration: 'underline', padding: 0, opacity: cancelingClose ? 0.5 : 1 }}>
76
77
  {cancelingClose ? '...' : 'Annuler'}
77
78
  </button>
78
79
  </span>
@@ -90,6 +91,7 @@ export function QuickActions({
90
91
  Ticket suivant : <strong>{nextTicketInfo}</strong>
91
92
  </span>
92
93
  <button
94
+ type="button"
93
95
  onClick={() => { window.location.href = `/admin/support/ticket?id=${nextTicketId}` }}
94
96
  style={{ ...s.btn(C.statusResolved), color: C.white, fontSize: '12px', padding: '5px 14px' }}
95
97
  >
@@ -99,7 +101,7 @@ export function QuickActions({
99
101
  ) : (
100
102
  <span style={{ fontSize: '13px', color: '#166534', fontWeight: 700 }}>{nextTicketInfo}</span>
101
103
  )}
102
- <button onClick={onCloseNextTicket} style={{ border: 'none', background: 'none', color: C.textMuted, cursor: 'pointer', fontSize: '16px', fontWeight: 700, marginLeft: '8px' }}>&times;</button>
104
+ <button type="button" onClick={onCloseNextTicket} style={{ border: 'none', background: 'none', color: C.textMuted, cursor: 'pointer', fontSize: '16px', fontWeight: 700, marginLeft: '8px' }}>&times;</button>
103
105
  </div>
104
106
  )}
105
107
  </>
@@ -71,6 +71,7 @@ export function TicketHeader({
71
71
  )}
72
72
  <span style={{ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
73
73
  <button
74
+ type="button"
74
75
  onClick={() => onCopyLink('admin')}
75
76
  title="Copier le lien admin"
76
77
  style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '13px', opacity: copiedLink === 'admin' ? 1 : 0.5, padding: '2px 4px' }}
@@ -78,6 +79,7 @@ export function TicketHeader({
78
79
  {copiedLink === 'admin' ? '\u2705' : '\uD83D\uDCCB'}
79
80
  </button>
80
81
  <button
82
+ type="button"
81
83
  onClick={() => onCopyLink('client')}
82
84
  title="Copier le lien client"
83
85
  style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '13px', opacity: copiedLink === 'client' ? 1 : 0.5, padding: '2px 4px' }}