@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
package/README.md CHANGED
@@ -26,6 +26,7 @@ ships as a template you copy into your own `app/` directory — see
26
26
  - [Quick Start](#quick-start)
27
27
  - [Mounting the client portal](#mounting-the-client-portal)
28
28
  - [Configuration](#configuration)
29
+ - [Database and updates](#database-and-updates)
29
30
  - [Deployment adapters](#deployment-adapters)
30
31
  - [API Endpoints](#api-endpoints)
31
32
  - [Webhooks](#webhooks)
@@ -34,6 +35,7 @@ ships as a template you copy into your own `app/` directory — see
34
35
  - [Requirements](#requirements)
35
36
  - [Security](#security)
36
37
  - [Performance](#performance)
38
+ - [Personal data, retention and uninstall](#personal-data-retention-and-uninstall)
37
39
  - [Troubleshooting](#troubleshooting)
38
40
  - [FAQ](#faq)
39
41
  - [Upgrading](#upgrading)
@@ -78,10 +80,10 @@ pnpm add @consilioweb/payload-support
78
80
  # or: yarn add @consilioweb/payload-support
79
81
  ```
80
82
 
81
- **Peer dependencies** — all six are required, none is optional:
83
+ **Peer dependencies** — all five are required, none is optional:
82
84
 
83
85
  ```bash
84
- pnpm add payload@^3.79.1 @payloadcms/next@^3.79.1 next@^15.2.9 react@^19 react-dom@^19 lucide-react@">=0.300.0"
86
+ pnpm add payload@^3.79.1 @payloadcms/next@^3.79.1 next@^15.2.9 react@^19 react-dom@^19
85
87
  ```
86
88
 
87
89
  - `payload@^3.79.1` — the floor is a security floor, and it is stated as such rather than
@@ -96,9 +98,10 @@ pnpm add payload@^3.79.1 @payloadcms/next@^3.79.1 next@^15.2.9 react@^19 react-d
96
98
  - `@payloadcms/next` and `next` — the 13 admin views are registered unless you pass
97
99
  `skipViews: true`, and each imports `DefaultTemplate` from `@payloadcms/next/templates` plus
98
100
  `next/navigation` / `next/link` statically. Both are already present in any Payload 3 admin app.
99
- - `lucide-react` imported statically by `PendingEmailsView` and `TicketingSettingsView`. (The
100
- third importer, `src/portal/LiveChat.tsx`, is a leftover: nothing in the shipped portal mounts
101
- it.)
101
+ `lucide-react` is **no longer a peer dependency**. The 19 icons the admin views and the portal
102
+ widget used are inlined as SVG in `src/views/shared/icons.tsx` and `src/portal/icons.tsx`, under
103
+ Lucide's ISC licence. Nothing to install; if your app already uses `lucide-react` for its own UI,
104
+ it is unaffected.
102
105
 
103
106
  **AI runtime dependency** — the AI features load `@anthropic-ai/sdk` through a runtime
104
107
  `require`/dynamic `import` and it is marked `external` at build time. It is intentionally not a
@@ -149,12 +152,13 @@ provider, the sender addresses, the SLA targets and the runtime feature flags.
149
152
  ## Mounting the client portal
150
153
 
151
154
  The portal ships as a **template you copy**, not as an importable subpath — and that is a
152
- constraint, not an oversight: `src/portal/auth/layout.tsx` and three of its pages import
155
+ constraint, not an oversight: `src/portal/auth/layout.tsx` and four of its pages import
153
156
  `@payload-config`, an alias that only exists inside your application, and `src/portal/layout.tsx`
154
- renders its own `<html>`/`<body>` as a Next.js **root layout**. Neither can be resolved from
155
- `node_modules`, so there is no `./portal` export and `plugin.ts` registers no Next route.
157
+ renders its own `<html>`/`<body>` (lines 13 and 24) as a Next.js **root layout**. Neither can be
158
+ resolved from `node_modules`, so there is no `./portal` export and `plugin.ts` registers no Next
159
+ route — adding one would break at import time in every host app.
156
160
 
157
- The sources are published in the tarball (`files` includes `src`), so copy them out of
161
+ The sources are published in the tarball (`files` includes `src`, 36 files), so copy them out of
158
162
  `node_modules` into a route group of your own `app/` directory. Two directories have to be
159
163
  renamed on the way in — the shipped names are plain folders, not Next.js route syntax — and
160
164
  `layout.tsx` has to sit at the **group** level, because it renders its own `<html>`/`<body>`
@@ -196,14 +200,13 @@ What your app must provide for those pages to work:
196
200
  | Requirement | Why |
197
201
  |---|---|
198
202
  | The `@payload-config` alias | The authenticated pages call `getPayload({ config })` directly. Standard in a `create-payload-app` project. |
199
- | **Tailwind CSS** | 28 of the 35 portal files are styled with Tailwind utility classes only. Without Tailwind the portal renders unstyled. |
203
+ | **Tailwind CSS** | 29 of the 32 portal components are styled with Tailwind utility classes only. Without Tailwind the portal renders unstyled. |
200
204
  | `@payloadcms/richtext-lexical` | `auth/faq/page.tsx` renders knowledge-base entries with `RichText` from `@payloadcms/richtext-lexical/react`. It is **not** a peer dependency of this plugin — add it yourself if you mount the FAQ page. |
201
205
  | `NEXT_PUBLIC_SERVER_URL` | Email and portal links are built from it. |
202
206
  | `NEXT_PUBLIC_SUPPORT_PHONE` | The ticket detail page prints a support phone number; without this variable it prints the placeholder `01 23 45 67 89` to your clients. |
203
207
 
204
- `lucide-react` is **not** needed by the pages above: the only portal file importing it is
205
- `src/portal/LiveChat.tsx`, which the shipped `(auth)` layout never mounts. It stays a required peer
206
- for the admin views.
208
+ No icon library is needed: `src/portal/icons.tsx` ships the five SVG icons `LiveChat.tsx` uses, and
209
+ it is copied along with the rest of the portal.
207
210
 
208
211
  The `(auth)` layout mounts two widgets. `ChatbotWidget` is always rendered; `ChatWidget` — the
209
212
  floating live-chat FAB — returns `null` unless the host app sets
@@ -221,6 +224,7 @@ plugin upgrades do not update it. Diff it against
221
224
  |---|---|---|---|
222
225
  | `features` | `SupportFeatures` | see below | Build-time feature flags — decide which collections, endpoints and views exist. |
223
226
  | `rateLimitStore` | `RateLimitStore \| 'payload'` | in-memory | Shared storage for the endpoint rate limits. `'payload'` adds the `support-rate-limits` collection. |
227
+ | `retention` | `{ authLogsDays?, emailLogsDays?, cron?, queue? } \| false` | **not set — no task is registered** | Opt-in automatic purge of the two journals, run as a Payload Jobs task. Pass `{}` to accept the defaults (`180` / `365` days, `0 30 3 * * *`, queue `default`). `0` on either value disables that journal's purge. Left unset, or `false`, registers nothing. See [Personal data, retention and uninstall](#personal-data-retention-and-uninstall). |
224
228
  | `ticketNumber` | `{ prefix?, padding? }` | `{ prefix: 'TK-', padding: 4 }` | Sequential ticket-number formatting, backed by an atomic counter. |
225
229
  | `capabilities` | `SupportCapabilities` | — | Host adapters for SMS, inbound email, digests, AI titles/summaries, detailed billing, volunteering, thread cleanup and project suggestions. See [Deployment adapters](#deployment-adapters). |
226
230
  | `basePath` | `string` | `'/support'` | Prefix of the admin view routes (`/admin<basePath>/inbox`, …). The conversation-import view is the exception: it is registered at the fixed `/admin/import-conversation`. |
@@ -361,6 +365,46 @@ of conditions (field, `equals` / `not_equals` / `contains`, value) and a list of
361
365
  (`set_status`, `set_priority`, `set_category`, `assign`, `add_tag`). For example: trigger
362
366
  `ticket_created`, condition `category equals bug`, action `set_priority = urgent`.
363
367
 
368
+ ## Database and updates
369
+
370
+ - This plugin **adds collections to your Payload config; it does not own your schema**. Your app
371
+ does, and it is your app that generates and runs the migrations.
372
+ - Payload gives a plugin no way to ship them. `payload migrate` reads exactly one directory —
373
+ `payload.db.migrationDir`, resolved from the **host application's** cwd
374
+ (`payload/dist/database/migrations/readMigrationFiles.js` and `findMigrationDir.js`). A migration
375
+ file published inside a package is never discovered; it is dead code.
376
+ - **In development**, `push` synchronises the schema on its own. Nothing else to do.
377
+ - **In production**, run `payload migrate:create` then `payload migrate` — or set `prodMigrations`
378
+ on the adapter. Never `push`: the adapter skips it as soon as `NODE_ENV=production`, and mixing a
379
+ dev-pushed database with migrations makes `@payloadcms/drizzle` warn about **data loss**.
380
+ - Every release of this plugin states in its [Upgrading](#upgrading) section whether it changes the
381
+ schema. Between 2.0.1 and 5.0.0, none did. The automatic log purge introduced after 5.0.0 does —
382
+ see the table below.
383
+
384
+ ### Options that change the schema
385
+
386
+ Two families of options decide which tables exist. Flipping one on a deployed application is a
387
+ schema change like any other: generate a migration and run it, in the same deploy.
388
+
389
+ | Option | Collection it adds or removes |
390
+ |---|---|
391
+ | `retention` (when you pass it) | Payload's own `payload-jobs` collection and `payload-jobs-stats` global — **only if your app had no jobs at all**. Declaring a task is what turns Payload's queue on, and that is why the option is opt-in: a support plugin has no business adding two tables to every install. Leave it unset and purge from your own cron against the admin endpoint instead. |
392
+ | `rateLimitStore: 'payload'` | `support-rate-limits` |
393
+ | `features.authLogs` | `auth-logs` |
394
+ | `features.timeTracking` | `time-entries` |
395
+ | `features.emailTracking` | `email-logs` |
396
+ | `features.webhooks` | `webhook-endpoints` |
397
+ | `features.sla` | `sla-policies` |
398
+ | `features.macros` | `macros` |
399
+ | `features.customStatuses` | `ticket-statuses` |
400
+ | `features.chat` | `chat-messages` |
401
+ | `features.pendingEmails` | `pending-emails` |
402
+ | `features.ai` | `client-summaries` |
403
+
404
+ Turning a flag **off** does not drop the table; the collection simply stops being registered, and
405
+ its rows become unreachable through Payload while still occupying the database. Drop it explicitly
406
+ if that is what you want — see [Uninstall](#uninstall).
407
+
364
408
  ## Deployment adapters
365
409
 
366
410
  The plugin owns the generic support workflow; anything provider-specific stays in your application
@@ -624,7 +668,6 @@ import type { SupportPluginConfig, SupportFeatures } from '@consilioweb/payload-
624
668
  | `@payloadcms/next` | `^3.79.1` | `peerDependencies` |
625
669
  | Next.js | `^15.2.9 \|\| ^16.0.0` | `peerDependencies` |
626
670
  | React / React DOM | `^19.0.0` | `peerDependencies` |
627
- | `lucide-react` | `>=0.300.0` | `peerDependencies` |
628
671
  | `@anthropic-ai/sdk` | any recent version | Runtime-only, install it yourself if you use the AI features |
629
672
  | `@payloadcms/richtext-lexical` | any 3.x | Only if you mount the portal FAQ page |
630
673
 
@@ -697,6 +740,106 @@ does:
697
740
  | Client time totals | The CRM sums the per-ticket `totalTimeMinutes` rollup instead of scanning `time-entries`. |
698
741
  | AI synthesis | Persisted on the ticket (`aiSummary`, `aiSummaryGeneratedAt`) and reused until forced. |
699
742
 
743
+ ## Personal data, retention and uninstall
744
+
745
+ This plugin is a support desk: most of what it stores is personal data, and you are the controller
746
+ of it. This section is what you need to fill in your record of processing activities.
747
+
748
+ ### What the plugin stores
749
+
750
+ It registers **up to 25 collections** (14 always, 11 behind a feature flag — see
751
+ [Options that change the schema](#options-that-change-the-schema)). The ones holding personal data:
752
+
753
+ | Collection | Personal data it holds |
754
+ |---|---|
755
+ | `support-clients` | Identity, email, phone, company, hashed password, Google id, 2FA state, notification preferences |
756
+ | `tickets` | Subject and description written by the client, assignment, billing amounts |
757
+ | `ticket-messages` | Full conversation, internal notes, attachments, sender addresses |
758
+ | `ticket-collaborators` | Email of every invited person, whether or not they have an account |
759
+ | `ticket-activity-log` | Who did what and when, on every ticket |
760
+ | `satisfaction-surveys`, `ticket-feedback` | Rating and free-text comment, attributed |
761
+ | `chat-messages` | Live-chat transcripts |
762
+ | `pending-emails` | Raw inbound emails, sender address, attachments |
763
+ | `auth-logs` | Login attempts: email, normalized IP, user agent |
764
+ | `email-logs` | Sender and recipient addresses, subjects, delivery and open events |
765
+ | `client-summaries` | **Derived** data: an AI profile of the client, computed from their history |
766
+ | `push-subscriptions` | Agent browser endpoints and user agent |
767
+ | `time-entries` | Which agent spent how long on what |
768
+ | `notification-queue` | Pending notifications addressed to a client |
769
+
770
+ Two things live **outside** those collections:
771
+
772
+ - **Attachments are stored in your own `media` collection**, because that is the `relationTo` of the
773
+ attachment fields on `ticket-messages` and `pending-emails`. They are not the plugin's to manage,
774
+ but `POST /support/delete-account` does delete the files attached to a deleted client's tickets.
775
+ - **Three `payload-preferences` keys** are written by the plugin: `support-settings` (instance
776
+ settings), `support-user-prefs-<userId>` (one row per agent) and `support-round-robin` (assignment
777
+ cursor). None of them holds client data, but the per-agent key carries a staff account id.
778
+
779
+ ### Retention
780
+
781
+ Only the two journals are purged automatically. Everything else lives as long as the ticket it
782
+ belongs to, and is deleted when the client account is.
783
+
784
+ | Data | Default | How to change it |
785
+ |---|---|---|
786
+ | `auth-logs` | **180 days** | `retention.authLogsDays`. CNIL guidance accepts six months for connection logs; do not shorten it "to be safe" — these are the records you investigate an intrusion with. |
787
+ | `email-logs` | **365 days** | `retention.emailLogsDays` |
788
+ | Everything else | Until the ticket or the account is deleted | `POST /support/delete-account` |
789
+
790
+ **The purge is opt-in: nothing is registered until you pass `retention`.** Pass `retention: {}` to
791
+ accept the defaults, or override any of the four keys. Once enabled it runs as a Payload Jobs task
792
+ (`support-purge-logs`), by default daily at 03:30, so it survives a serverless deploy and does not
793
+ run N times in parallel behind N instances. It refuses a retention of `0` — that value disables the
794
+ journal's purge. The manual, admin-only
795
+ `DELETE /api/support/purge-logs?collection=auth-logs&days=0` still wipes a journal entirely; that
796
+ one is a deliberate one-off action, never a schedule.
797
+
798
+ Why opt-in, when a retention policy nobody enables is exactly how the manual purge endpoint ended
799
+ up never being called: declaring the task **enables Payload's job queue**, which adds the
800
+ `payload-jobs` collection and the `payload-jobs-stats` global — two tables your app may not have
801
+ had. A support plugin should not impose a schema change on every install, and defaulting it on
802
+ would buy nothing anyway, since the task only fires when you also run a job runner. If you enable
803
+ it, generate a migration for those two. If you would rather not, leave `retention` unset and call
804
+ the purge endpoint from your own cron — the retention periods below still describe what you should
805
+ be deleting.
806
+
807
+ Payload's job runner has to be running for the task to fire — `jobs.autoRun` in your config, or a
808
+ cron hitting `/api/payload-jobs/run`. If you do not run it, nothing is purged and the endpoint is
809
+ your only lever.
810
+
811
+ ### Cookies and trackers
812
+
813
+ `features.emailTracking` is **on by default** and inserts a 1×1 open-tracking pixel in outgoing
814
+ notification emails (`GET /support/track-open`). Under article 82 of the French *Loi Informatique
815
+ et Libertés* that is a tracker and needs consent unless it falls in an exemption; it is your call,
816
+ not the plugin's. It writes no IP address and requires a valid HMAC, but it does record that a
817
+ given recipient opened a given email, at a given time. Set `features: { emailTracking: false }` to
818
+ turn the pixel — and the `email-logs` collection — off entirely.
819
+
820
+ ### Data subject requests
821
+
822
+ | Right | Endpoint |
823
+ |---|---|
824
+ | Access (art. 15) and portability (art. 20) | `GET /support/export-data`. The JSON is split in two: `providedByYou` (art. 20) and `derivedData` (art. 15 — the AI client summary, which portability does not cover). |
825
+ | Erasure (art. 17) | `POST /support/delete-account`. Deletes the account and every row above that names it, plus the attachments on its tickets, in a single transaction. |
826
+
827
+ ### Uninstall
828
+
829
+ Removing the plugin from `payload.config.ts` stops the collections from being registered; it does
830
+ **not** drop their tables, and the rows stay in the database.
831
+
832
+ ```bash
833
+ npx support-uninstall # dry run: lists what would be deleted
834
+ npx support-uninstall --confirm # deletes the rows through the Payload local API
835
+ npx support-uninstall --confirm --keep-data # only removes the payload-preferences keys
836
+ ```
837
+
838
+ The script deletes rows, not tables: run `payload migrate:create` after it and apply the migration
839
+ to drop the now-unused tables. It never touches your `media` collection — attachments uploaded
840
+ through the support desk are indistinguishable from your other uploads once the messages are gone,
841
+ so it lists them as a manual decision instead of guessing.
842
+
700
843
  ## Troubleshooting
701
844
 
702
845
  **`useServerFunctions must be used within ServerFunctionsProvider`** — align every `@payloadcms/*`
@@ -708,10 +851,12 @@ inserts) and add `busyTimeout: 10000` to the SQLite adapter.
708
851
  **Admin views do not load** — regenerate the import map (`pnpm payload generate:importmap`). In a
709
852
  headless context, pass `skipViews: true`.
710
853
 
711
- **New fields or collections missing in production** — standalone builds do not run migrations. Push
712
- the schema before deploying, including `googleId`, `twoFactorVerifiedAt`, `slaPausedAt`, `nps`,
713
- `mentions`, and the `notification-queue`, `automation-rules`, `ticket-collaborators`,
714
- `support-counters` collections.
854
+ **New fields or collections missing in production** — a production build never synchronises the
855
+ schema: the adapter skips `push` as soon as `NODE_ENV=production`. Generate a migration in
856
+ development (`payload migrate:create`) and run `payload migrate` on the target, or set
857
+ `prodMigrations` on the adapter. Do **not** try to `push` in production, and do not migrate a
858
+ database that was previously dev-pushed without reading the data-loss warning
859
+ `@payloadcms/drizzle` prints. See [Database and updates](#database-and-updates).
715
860
 
716
861
  **`Cannot find module '@anthropic-ai/sdk'`** — install it in your app; the plugin does not bundle or
717
862
  declare it.
@@ -733,11 +878,84 @@ type but not implemented: at runtime everything that is not `ollama` goes to Ant
733
878
  (`SupportPluginConfig`, `SupportFeatures`, `TicketData`, …). All four export subpaths ship
734
879
  declarations.
735
880
 
736
- **How do I migrate the schema in production?** — in standalone mode the schema is not
737
- auto-migrated: generate and push it before deploying. See [Troubleshooting](#troubleshooting).
881
+ **How do I migrate the schema in production?** — `payload migrate:create` in development, commit
882
+ the generated file, then `payload migrate` on the target (or `prodMigrations` on the adapter).
883
+ `push` is a development mechanism and is skipped in production. This plugin ships no migrations of
884
+ its own and cannot: `payload migrate` only ever reads your application's migration directory. See
885
+ [Database and updates](#database-and-updates).
738
886
 
739
887
  ## Upgrading
740
888
 
889
+ ### 5.x → 6.0
890
+
891
+ Two changes need an action; everything else in 6.0 is additive.
892
+
893
+ **`GET /support/export-data` changed shape.** `profile`, `tickets`, `messages` and `surveys` now
894
+ sit under `providedByYou`, and a `derivedData` section carries what the service inferred rather
895
+ than what the person supplied — the distinction GDPR article 20 (portability, provided data) and
896
+ article 15 (access, all data) actually make. If anything of yours parses that response, read the
897
+ new keys. The endpoint is the only one whose shape moved.
898
+
899
+ **`lucide-react` is no longer a peer dependency.** The twenty icons are inlined, so the package no
900
+ longer requires you to install an icon library. Nothing breaks if you keep it — it is simply not
901
+ requested any more. Remove it from your own dependencies only if nothing else of yours uses it.
902
+
903
+ Two smaller notes. The exported `V` colour tokens changed value: `amber`, `orange`, `green` (and
904
+ the `yellow` alias) are darker, and `textSecondary` moved off `--theme-elevation-500`, which failed
905
+ WCAG AA as a foreground. If you render your own UI with `V`, expect a visual shift, not a break.
906
+ And the log retention purge is **opt-in**: it registers nothing unless you pass `retention`. See
907
+ [Personal data, retention and uninstall](#personal-data-retention-and-uninstall) for why, and for
908
+ what to run instead if you would rather purge from your own cron.
909
+
910
+ ### 4.x → 5.0
911
+
912
+ **No schema change: this release only touches access rules and hooks. Do not generate a
913
+ migration.** The field set of 5.0.0 is identical to 4.0.0's.
914
+
915
+ Data actions to take:
916
+
917
+ 1. **Audit your access logs for `<adminRoute>/support/*` hits from accounts that are neither agents
918
+ nor `support-clients`.** Until 5.0.0 the thirteen admin views only tested `!req.user`, so any
919
+ authenticated account of any auth collection reached the admin chrome and the client config
920
+ Payload builds for an authenticated request. The support data itself never travelled — the views
921
+ fetch through `/api/support/*`, which was guarded — but the shape of your CMS did.
922
+ 2. **Re-subscribe any agent whose Web Push endpoint is not `https://` on a public host.**
923
+ `POST /support/push/subscribe` now answers `400` for those, and `sendPushToUser` skips rows
924
+ already stored that fail the same check. Agents who subscribed through a local tunnel or an
925
+ `http://` origin stop receiving notifications until they subscribe again.
926
+ 3. **Expect the rate-limit counters to restart once.** Keys are now normalized IP literals, so rows
927
+ already in `support-rate-limits` no longer match. This is a one-off reset, not a data loss.
928
+ 4. **A custom portal must forward `challenge`** from every `POST /support/2fa {action:'send'}`
929
+ reply to the following `verify`; the bundled portal already does.
930
+
931
+ ### 3.x → 4.0
932
+
933
+ **No schema change: this release only touches access rules and hooks. Do not generate a
934
+ migration.** The field set of 4.0.0 is identical to 3.0.0's.
935
+
936
+ Data actions to take:
937
+
938
+ 1. **Audit every `webhook-endpoints` row and re-point the ones that are not `https://` on a public
939
+ host.** 4.0.0 validates the URL at save time only, so a row saved earlier stays editable and
940
+ still shows in the admin — it simply stops firing, and the dispatcher records `lastStatus: 0` on
941
+ it. Nothing tells you which rows are in that state, so list them:
942
+ `payload.find({ collection: 'webhook-endpoints' })` and look for `http://`, a private, loopback
943
+ or link-local host. `SUPPORT_ALLOW_INSECURE_WEBHOOKS=1` re-allows `http://` for local
944
+ development only.
945
+ 2. **Review the collaborator rows created before 4.0.0.** A row with no explicit `role` now defaults
946
+ to `viewer`, which is read-only: an invitee who used to post messages stops being able to. Set
947
+ their row to `collaborator` to restore it.
948
+ 3. **Expect the rate-limit counters to restart once.** `RateLimiter` now namespaces every key and
949
+ keys authenticated callers as `<collection>:<id>`, so rows already in `support-rate-limits` no
950
+ longer match. One-off reset.
951
+ 4. **Drop `cookieState` from a hand-written Google OAuth button.** `{ action: 'login' }` now sets an
952
+ `HttpOnly` `support-oauth-state` cookie; the callback must let the browser carry it
953
+ (`credentials: 'include'` cross-origin).
954
+ 5. **A dashboard calling `GET /support/email-stats` with a portal client session now gets `403`** —
955
+ it is staff-only.
956
+ 6. **If your app replaces `config.custom` wholesale after the plugin runs**, it strips
957
+ `custom.supportStaffCollection` and the settings reads lose the staff slug they compare against.
958
+
741
959
  ### 2.x → 3.0
742
960
 
743
961
  1. **Rotate the secret of every webhook endpoint.** The legacy transport sent it in clear in
@@ -752,7 +970,8 @@ auto-migrated: generate and push it before deploying. See [Troubleshooting](#tro
752
970
  ticket.
753
971
  5. **Import `./views` and `./components/TicketConversation` with ESM** — their CJS builds are gone.
754
972
  6. Check your platform: Node 20.9+, React 19, Next 15.2.9+, Payload 3.79.1+, and install
755
- `lucide-react` explicitly if you were relying on it being optional.
973
+ `lucide-react` explicitly if you were relying on it being optional. (It has since stopped being
974
+ a peer dependency altogether — the icons are inlined.)
756
975
 
757
976
  ### 1.x → 2.0
758
977
 
@@ -773,7 +992,7 @@ auto-migrated: generate and push it before deploying. See [Troubleshooting](#tro
773
992
  3. Commit your changes (`git commit -m 'feat: add AmazingFeature'`).
774
993
  4. Push the branch and open a Pull Request.
775
994
 
776
- Run the checks before submitting — the suite is 179 vitest tests:
995
+ Run the checks before submitting — the suite is 391 vitest tests:
777
996
 
778
997
  ```bash
779
998
  pnpm typecheck && pnpm test && pnpm build
@@ -791,6 +1010,14 @@ If this plugin saves you time, consider [buying me a coffee](https://buymeacoffe
791
1010
 
792
1011
  MIT — see [LICENSE](LICENSE).
793
1012
 
1013
+ **Runtime dependencies.** The package has exactly three, declared in `package.json`: `pdfkit`
1014
+ (^0.19.1, MIT), `sanitize-html` (^2.17.7, MIT) and `web-push` (^3.6.7, **MPL-2.0**). MPL-2.0 is the
1015
+ only reciprocal licence in that set, and its copyleft is per **file**, not per work: §3.3 lets a
1016
+ Larger Work be distributed under other terms. `payload-support` neither modifies nor bundles
1017
+ `web-push` — it imports it as an external dependency, which you can check in `dist/index.js:5` and
1018
+ `dist/index.cjs:7` — so the obligation is already met by npm shipping `web-push` with its own
1019
+ licence. The licence of this plugin is unaffected: it stays MIT.
1020
+
794
1021
  Built and maintained by [ConsilioWEB](https://consilioweb.fr) ·
795
1022
  [Issues](https://github.com/pOwn3d/payload-support/issues) ·
796
1023
  [npm](https://www.npmjs.com/package/@consilioweb/payload-support)
@@ -255,7 +255,6 @@ const RichTextEditor = forwardRef(function RichTextEditor2({
255
255
  fontSize: "14px",
256
256
  lineHeight: 1.6,
257
257
  color: "#1f2937",
258
- outline: "none",
259
258
  overflowY: "auto"
260
259
  }
261
260
  }
@@ -268,6 +267,7 @@ const RichTextEditor = forwardRef(function RichTextEditor2({
268
267
  type: "file",
269
268
  accept: "image/*",
270
269
  multiple: true,
270
+ "aria-label": "Inserer une image",
271
271
  onChange: handleFileChange,
272
272
  style: { display: "none" }
273
273
  }
@@ -80,7 +80,7 @@ const RewriteDropdown = ({ disabled, loading, onSelect }) => {
80
80
  },
81
81
  children: [
82
82
  /* @__PURE__ */ jsx("span", { style: { fontSize: 12, fontWeight: 600, color: "var(--theme-text, #111)" }, children: style.label }),
83
- /* @__PURE__ */ jsx("span", { style: { fontSize: 10, color: "var(--theme-elevation-500, #6b7280)" }, children: style.desc })
83
+ /* @__PURE__ */ jsx("span", { style: { fontSize: 10, color: "var(--theme-elevation-650, #6b7280)" }, children: style.desc })
84
84
  ]
85
85
  },
86
86
  style.id
@@ -31,6 +31,7 @@ function AISummaryPanel({
31
31
  /* @__PURE__ */ jsx(
32
32
  "button",
33
33
  {
34
+ type: "button",
34
35
  onClick: () => {
35
36
  setShowAiSummary(!showAiSummary);
36
37
  if (!showAiSummary && !aiSummary) handleAiGenerate();
@@ -58,6 +59,7 @@ function AISummaryPanel({
58
59
  /* @__PURE__ */ jsx(
59
60
  "button",
60
61
  {
62
+ type: "button",
61
63
  onClick: handleAiGenerate,
62
64
  disabled: aiGenerating,
63
65
  style: { ...s.outlineBtn("#17807c", aiGenerating), fontSize: "11px", padding: "4px 10px" },
@@ -67,6 +69,7 @@ function AISummaryPanel({
67
69
  aiSummary && !aiGenerating && /* @__PURE__ */ jsx(
68
70
  "button",
69
71
  {
72
+ type: "button",
70
73
  onClick: handleAiSave,
71
74
  disabled: aiSaving || aiSaved,
72
75
  style: { ...s.btn(aiSaved ? "#16a34a" : "#2563eb", aiSaving), fontSize: "11px", padding: "4px 10px" },
@@ -21,6 +21,7 @@ function MergePanel({
21
21
  "input",
22
22
  {
23
23
  type: "text",
24
+ "aria-label": "Numero du ticket cible",
24
25
  value: mergeTarget,
25
26
  onChange: (e) => {
26
27
  setMergeTarget(e.target.value);
@@ -31,7 +32,7 @@ function MergePanel({
31
32
  style: { ...s.input, width: "130px" }
32
33
  }
33
34
  ),
34
- /* @__PURE__ */ jsx("button", { onClick: handleMergeLookup, style: { ...s.outlineBtn("#ec4899"), fontSize: "12px", padding: "6px 14px" }, children: "Rechercher" }),
35
+ /* @__PURE__ */ jsx("button", { type: "button", onClick: handleMergeLookup, style: { ...s.outlineBtn("#ec4899"), fontSize: "12px", padding: "6px 14px" }, children: "Rechercher" }),
35
36
  mergeError && /* @__PURE__ */ jsx("span", { style: { fontSize: "12px", color: "#be185d", fontWeight: 600 }, children: mergeError })
36
37
  ] }),
37
38
  mergeTargetInfo && /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: "8px", alignItems: "center" }, children: [
@@ -40,7 +41,7 @@ function MergePanel({
40
41
  " \u2014 ",
41
42
  mergeTargetInfo.subject
42
43
  ] }),
43
- /* @__PURE__ */ jsx("button", { onClick: handleMerge, disabled: merging, style: { ...s.btn("#ec4899", merging), color: "#fff", fontSize: "12px", padding: "6px 14px" }, children: merging ? "Fusion..." : "Confirmer la fusion" })
44
+ /* @__PURE__ */ jsx("button", { type: "button", onClick: handleMerge, disabled: merging, style: { ...s.btn("#ec4899", merging), color: "#fff", fontSize: "12px", padding: "6px 14px" }, children: merging ? "Fusion..." : "Confirmer la fusion" })
44
45
  ] })
45
46
  ] });
46
47
  }
@@ -63,6 +64,7 @@ function ExtMessagePanel({
63
64
  /* @__PURE__ */ jsx(
64
65
  "textarea",
65
66
  {
67
+ "aria-label": "Contenu du message recu",
66
68
  value: extMsgBody,
67
69
  onChange: (e) => setExtMsgBody(e.target.value),
68
70
  placeholder: "Coller le contenu du message re\xE7u...",
@@ -71,7 +73,7 @@ function ExtMessagePanel({
71
73
  }
72
74
  ),
73
75
  /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: "8px", alignItems: "center", flexWrap: "wrap", marginBottom: "10px" }, children: [
74
- /* @__PURE__ */ jsxs("select", { value: extMsgAuthor, onChange: (e) => setExtMsgAuthor(e.target.value), style: { ...s.input, fontSize: "12px" }, children: [
76
+ /* @__PURE__ */ jsxs("select", { "aria-label": "Auteur du message recu", value: extMsgAuthor, onChange: (e) => setExtMsgAuthor(e.target.value), style: { ...s.input, fontSize: "12px" }, children: [
75
77
  /* @__PURE__ */ jsx("option", { value: "client", children: "Envoy\xE9 par le client" }),
76
78
  /* @__PURE__ */ jsx("option", { value: "admin", children: "Envoy\xE9 par le support" })
77
79
  ] }),
@@ -79,19 +81,20 @@ function ExtMessagePanel({
79
81
  "input",
80
82
  {
81
83
  type: "datetime-local",
84
+ "aria-label": "Date du message recu",
82
85
  value: extMsgDate,
83
86
  onChange: (e) => setExtMsgDate(e.target.value),
84
87
  style: { ...s.input, fontSize: "12px" }
85
88
  }
86
89
  ),
87
- /* @__PURE__ */ jsx("input", { ref: extFileInputRef, type: "file", multiple: true, onChange: handleExtFileChange, style: { display: "none" }, accept: "image/*,.pdf,.doc,.docx,.txt,.zip" }),
90
+ /* @__PURE__ */ jsx("input", { ref: extFileInputRef, type: "file", multiple: true, "aria-label": "Joindre des fichiers au message recu", onChange: handleExtFileChange, style: { display: "none" }, accept: "image/*,.pdf,.doc,.docx,.txt,.zip" }),
88
91
  /* @__PURE__ */ jsx("button", { type: "button", onClick: () => extFileInputRef.current?.click(), style: { ...s.ghostBtn("#6b7280"), fontSize: "12px", padding: "6px 12px" }, children: "+ PJ" })
89
92
  ] }),
90
93
  extMsgFiles.length > 0 && /* @__PURE__ */ jsx("div", { style: { marginBottom: "10px", display: "flex", gap: "6px", flexWrap: "wrap" }, children: extMsgFiles.map((file, i) => /* @__PURE__ */ jsxs("span", { style: { ...s.badge("#f1f5f9", "#374151"), display: "inline-flex", alignItems: "center", gap: "4px" }, children: [
91
94
  file.name,
92
95
  /* @__PURE__ */ jsx("button", { type: "button", onClick: () => setExtMsgFiles((prev) => prev.filter((_, idx) => idx !== i)), style: { border: "none", background: "none", color: "#ef4444", fontWeight: 700, cursor: "pointer", fontSize: "14px", lineHeight: 1 }, children: "\xD7" })
93
96
  ] }, i)) }),
94
- /* @__PURE__ */ jsx("button", { onClick: handleSendExtMsg, disabled: sendingExtMsg || !extMsgBody.trim(), style: s.btn("#1d2b4d", sendingExtMsg || !extMsgBody.trim()), children: sendingExtMsg ? "Ajout..." : "Ajouter (sans notification)" })
97
+ /* @__PURE__ */ jsx("button", { type: "button", onClick: handleSendExtMsg, disabled: sendingExtMsg || !extMsgBody.trim(), style: s.btn("#1d2b4d", sendingExtMsg || !extMsgBody.trim()), children: sendingExtMsg ? "Ajout..." : "Ajouter (sans notification)" })
95
98
  ] });
96
99
  }
97
100
  const REMINDER_DELAYS = [
@@ -157,6 +160,7 @@ function ReminderPanel({
157
160
  /* @__PURE__ */ jsx(
158
161
  "button",
159
162
  {
163
+ type: "button",
160
164
  onClick: handleSendReminder,
161
165
  disabled: reminderSending || !clientEmail,
162
166
  style: { ...s.btn(C.orange, reminderSending || !clientEmail), color: "#fff", fontSize: "13px", padding: "8px 18px" },
@@ -169,13 +173,14 @@ function SnoozePanel({ snoozeSaving, handleSnooze }) {
169
173
  return /* @__PURE__ */ jsxs("div", { style: { padding: "14px 18px", borderRadius: "8px", backgroundColor: "#eef8f7", border: "1px solid #b8dcda", marginBottom: "14px" }, children: [
170
174
  /* @__PURE__ */ jsx("h4", { style: { fontSize: "13px", fontWeight: 600, marginBottom: "10px", color: "#5b21b6" }, children: "Snooze \u2014 masquer temporairement" }),
171
175
  /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: "8px", flexWrap: "wrap", alignItems: "center" }, children: [
172
- /* @__PURE__ */ jsx("button", { onClick: () => handleSnooze(1), disabled: snoozeSaving, style: { ...s.outlineBtn("#17807c", snoozeSaving), fontSize: "12px" }, children: "1 jour" }),
173
- /* @__PURE__ */ jsx("button", { onClick: () => handleSnooze(3), disabled: snoozeSaving, style: { ...s.outlineBtn("#17807c", snoozeSaving), fontSize: "12px" }, children: "3 jours" }),
174
- /* @__PURE__ */ jsx("button", { onClick: () => handleSnooze(7), disabled: snoozeSaving, style: { ...s.outlineBtn("#17807c", snoozeSaving), fontSize: "12px" }, children: "1 semaine" }),
176
+ /* @__PURE__ */ jsx("button", { type: "button", onClick: () => handleSnooze(1), disabled: snoozeSaving, style: { ...s.outlineBtn("#17807c", snoozeSaving), fontSize: "12px" }, children: "1 jour" }),
177
+ /* @__PURE__ */ jsx("button", { type: "button", onClick: () => handleSnooze(3), disabled: snoozeSaving, style: { ...s.outlineBtn("#17807c", snoozeSaving), fontSize: "12px" }, children: "3 jours" }),
178
+ /* @__PURE__ */ jsx("button", { type: "button", onClick: () => handleSnooze(7), disabled: snoozeSaving, style: { ...s.outlineBtn("#17807c", snoozeSaving), fontSize: "12px" }, children: "1 semaine" }),
175
179
  /* @__PURE__ */ jsx(
176
180
  "input",
177
181
  {
178
182
  type: "datetime-local",
183
+ "aria-label": "Snoozer jusqu'a une date precise",
179
184
  onChange: (e) => {
180
185
  if (e.target.value) handleSnooze(null, e.target.value);
181
186
  },
@@ -113,6 +113,7 @@ function ClientHistory({
113
113
  /* @__PURE__ */ jsx(
114
114
  "textarea",
115
115
  {
116
+ "aria-label": "Notes internes sur le client",
116
117
  value: clientNotes,
117
118
  onChange: (e) => onNotesChange(e.target.value),
118
119
  rows: 3,
@@ -83,6 +83,7 @@ function SingleCodeBlock({ lang, code }) {
83
83
  /* @__PURE__ */ jsx(
84
84
  "button",
85
85
  {
86
+ type: "button",
86
87
  onClick: handleCopy,
87
88
  style: {
88
89
  background: "none",
@@ -95,6 +95,7 @@ function CodeBlockInserter({ onInsert, className, style }) {
95
95
  {
96
96
  ref: inputRef,
97
97
  type: "text",
98
+ "aria-label": "Rechercher un langage",
98
99
  value: filter,
99
100
  onChange: (e) => setFilter(e.target.value),
100
101
  onKeyDown: (e) => {
@@ -113,7 +114,6 @@ function CodeBlockInserter({ onInsert, className, style }) {
113
114
  border: "1px solid #334155",
114
115
  borderRadius: 6,
115
116
  color: "#e2e8f0",
116
- outline: "none",
117
117
  boxSizing: "border-box"
118
118
  }
119
119
  }
@@ -33,6 +33,7 @@ function QuickActions({
33
33
  statusTransitions.map((a) => /* @__PURE__ */ jsx(
34
34
  "button",
35
35
  {
36
+ type: "button",
36
37
  onClick: () => onStatusChange(a.status),
37
38
  disabled: statusUpdating,
38
39
  style: s.outlineBtn(a.color, statusUpdating),
@@ -40,21 +41,21 @@ function QuickActions({
40
41
  },
41
42
  a.status
42
43
  )),
43
- snoozeUntil && new Date(snoozeUntil) > /* @__PURE__ */ new Date() && /* @__PURE__ */ jsx("button", { onClick: onCancelSnooze, disabled: snoozeSaving, style: { ...s.ghostBtn("#17807c", snoozeSaving), fontSize: "12px", padding: "5px 10px" }, children: "Annuler snooze" }),
44
+ snoozeUntil && new Date(snoozeUntil) > /* @__PURE__ */ new Date() && /* @__PURE__ */ jsx("button", { type: "button", onClick: onCancelSnooze, disabled: snoozeSaving, style: { ...s.ghostBtn("#17807c", snoozeSaving), fontSize: "12px", padding: "5px 10px" }, children: "Annuler snooze" }),
44
45
  /* @__PURE__ */ jsx("span", { style: { borderLeft: `1px solid ${C.border}`, height: "20px", margin: "0 4px" } }),
45
- /* @__PURE__ */ jsx("button", { onClick: onToggleMerge, style: s.ghostBtn("#be185d"), children: "Fusionner" }),
46
- /* @__PURE__ */ jsx("button", { onClick: onToggleExtMsg, style: s.ghostBtn("#4f46e5"), children: "+ Message re\xE7u" }),
47
- /* @__PURE__ */ jsx("button", { onClick: onToggleSnooze, style: s.ghostBtn("#17807c"), children: "Snooze" }),
48
- showReminderButton && /* @__PURE__ */ jsxs("button", { onClick: onToggleReminder, style: s.ghostBtn("#ea580c"), children: [
46
+ /* @__PURE__ */ jsx("button", { type: "button", onClick: onToggleMerge, style: s.ghostBtn("#be185d"), children: "Fusionner" }),
47
+ /* @__PURE__ */ jsx("button", { type: "button", onClick: onToggleExtMsg, style: s.ghostBtn("#4f46e5"), children: "+ Message re\xE7u" }),
48
+ /* @__PURE__ */ jsx("button", { type: "button", onClick: onToggleSnooze, style: s.ghostBtn("#17807c"), children: "Snooze" }),
49
+ showReminderButton && /* @__PURE__ */ jsxs("button", { type: "button", onClick: onToggleReminder, style: s.ghostBtn("#ea580c"), children: [
49
50
  "\u23F0",
50
51
  " Relancer"
51
52
  ] }),
52
- /* @__PURE__ */ jsx("button", { onClick: onNextTicket, style: s.ghostBtn("#16a34a"), children: "Ticket suivant" }),
53
+ /* @__PURE__ */ jsx("button", { type: "button", onClick: onNextTicket, style: s.ghostBtn("#16a34a"), children: "Ticket suivant" }),
53
54
  scheduledClose && /* @__PURE__ */ jsxs("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 }, children: [
54
55
  "\u23F0",
55
56
  " Fermeture auto le ",
56
57
  scheduledClose,
57
- /* @__PURE__ */ jsx("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 }, children: cancelingClose ? "..." : "Annuler" })
58
+ /* @__PURE__ */ jsx("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 }, children: cancelingClose ? "..." : "Annuler" })
58
59
  ] }),
59
60
  reminderSuccess && /* @__PURE__ */ jsxs("span", { style: { ...s.badge("#f0fdf4", "#16a34a"), fontSize: "12px", padding: "5px 10px" }, children: [
60
61
  "Relance envoy\xE9e ",
@@ -70,6 +71,7 @@ function QuickActions({
70
71
  /* @__PURE__ */ jsx(
71
72
  "button",
72
73
  {
74
+ type: "button",
73
75
  onClick: () => {
74
76
  window.location.href = `/admin/support/ticket?id=${nextTicketId}`;
75
77
  },
@@ -78,7 +80,7 @@ function QuickActions({
78
80
  }
79
81
  )
80
82
  ] }) : /* @__PURE__ */ jsx("span", { style: { fontSize: "13px", color: "#166534", fontWeight: 700 }, children: nextTicketInfo }),
81
- /* @__PURE__ */ jsx("button", { onClick: onCloseNextTicket, style: { border: "none", background: "none", color: C.textMuted, cursor: "pointer", fontSize: "16px", fontWeight: 700, marginLeft: "8px" }, children: "\xD7" })
83
+ /* @__PURE__ */ jsx("button", { type: "button", onClick: onCloseNextTicket, style: { border: "none", background: "none", color: C.textMuted, cursor: "pointer", fontSize: "16px", fontWeight: 700, marginLeft: "8px" }, children: "\xD7" })
82
84
  ] })
83
85
  ] });
84
86
  }
@@ -69,6 +69,7 @@ function TicketHeader({
69
69
  /* @__PURE__ */ jsx(
70
70
  "button",
71
71
  {
72
+ type: "button",
72
73
  onClick: () => onCopyLink("admin"),
73
74
  title: "Copier le lien admin",
74
75
  style: { background: "none", border: "none", cursor: "pointer", fontSize: "13px", opacity: copiedLink === "admin" ? 1 : 0.5, padding: "2px 4px" },
@@ -78,6 +79,7 @@ function TicketHeader({
78
79
  /* @__PURE__ */ jsx(
79
80
  "button",
80
81
  {
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" },