@consilioweb/payload-support 0.10.0 → 0.11.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 (42) hide show
  1. package/dist/components/TicketConversation/locales/en.json +25 -1
  2. package/dist/components/TicketConversation/locales/fr.json +25 -1
  3. package/dist/index.cjs +799 -4
  4. package/dist/index.d.cts +15 -1
  5. package/dist/index.d.ts +15 -1
  6. package/dist/index.js +800 -6
  7. package/dist/styles/TicketDetail.module.scss +294 -0
  8. package/dist/views/TicketDetailView/client.cjs +455 -249
  9. package/dist/views/TicketDetailView/client.js +456 -250
  10. package/dist/views/TicketInboxView/client.cjs +10 -9
  11. package/dist/views/TicketInboxView/client.js +2 -1
  12. package/package.json +1 -1
  13. package/src/collections/ClientSummaries.ts +16 -0
  14. package/src/collections/TicketCollaborators.ts +93 -0
  15. package/src/collections/TicketFeedback.ts +116 -0
  16. package/src/collections/TicketMessages.ts +9 -0
  17. package/src/collections/Tickets.ts +31 -1
  18. package/src/collections/index.ts +2 -0
  19. package/src/components/TicketConversation/locales/en.json +25 -1
  20. package/src/components/TicketConversation/locales/fr.json +25 -1
  21. package/src/endpoints/escalate.ts +44 -0
  22. package/src/endpoints/index.ts +15 -0
  23. package/src/endpoints/invite-collaborator.ts +215 -0
  24. package/src/endpoints/kb-search.ts +156 -0
  25. package/src/endpoints/ticket-feedback.ts +104 -0
  26. package/src/endpoints/transfer-ticket.ts +248 -0
  27. package/src/index.ts +1 -0
  28. package/src/plugin.ts +4 -0
  29. package/src/portal/auth/ChatWidget.tsx +11 -0
  30. package/src/portal/auth/dashboard/DashboardClient.tsx +85 -99
  31. package/src/portal/auth/dashboard/page.tsx +11 -2
  32. package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +402 -0
  33. package/src/portal/auth/tickets/detail/page.tsx +122 -23
  34. package/src/portal/auth/tickets/new/KbDeflection.tsx +128 -0
  35. package/src/portal/auth/tickets/new/page.tsx +203 -100
  36. package/src/portal/locales/en.json +5 -0
  37. package/src/portal/locales/fr.json +5 -0
  38. package/src/styles/TicketDetail.module.scss +294 -0
  39. package/src/types.ts +19 -0
  40. package/src/utils/slugs.ts +2 -0
  41. package/src/views/TicketDetailView/client.tsx +346 -89
  42. package/src/views/TicketInboxView/client.tsx +2 -1
@@ -0,0 +1,128 @@
1
+ 'use client'
2
+
3
+ import React, { useEffect, useState } from 'react'
4
+
5
+ interface KbResult {
6
+ id: number | string
7
+ title: string
8
+ slug: string
9
+ excerpt: string
10
+ category: string
11
+ score: number
12
+ }
13
+
14
+ interface KbDeflectionProps {
15
+ query: string
16
+ /** Max number of suggestions to show (default 3). */
17
+ limit?: number
18
+ }
19
+
20
+ /**
21
+ * Live KB suggestions under the "Subject" field of the new-ticket form.
22
+ *
23
+ * - Debounces the query (400ms) to avoid hitting the endpoint on each keystroke.
24
+ * - Hides itself when the query is too short (< 3 chars) or returns no results.
25
+ * - Renders nothing while loading the first time to avoid flicker.
26
+ *
27
+ * The goal is deflection: let the client find an answer before submitting a
28
+ * ticket. Opens articles in a new tab so the form state is preserved.
29
+ */
30
+ export default function KbDeflection({ query, limit = 3 }: KbDeflectionProps) {
31
+ const [results, setResults] = useState<KbResult[]>([])
32
+ const [dismissed, setDismissed] = useState(false)
33
+
34
+ useEffect(() => {
35
+ const trimmed = (query || '').trim()
36
+ if (trimmed.length < 3) {
37
+ setResults([])
38
+ return
39
+ }
40
+
41
+ const ctrl = new AbortController()
42
+ const timer = window.setTimeout(async () => {
43
+ try {
44
+ const res = await fetch(
45
+ `/api/support/kb/search?q=${encodeURIComponent(trimmed)}&limit=${limit}`,
46
+ { signal: ctrl.signal, credentials: 'include' },
47
+ )
48
+ if (!res.ok) {
49
+ setResults([])
50
+ return
51
+ }
52
+ const data = (await res.json()) as { results?: KbResult[] }
53
+ setResults(Array.isArray(data.results) ? data.results : [])
54
+ } catch (err) {
55
+ if ((err as Error).name === 'AbortError') return
56
+ setResults([])
57
+ }
58
+ }, 400)
59
+
60
+ return () => {
61
+ ctrl.abort()
62
+ window.clearTimeout(timer)
63
+ }
64
+ }, [query, limit])
65
+
66
+ // Reset dismissal whenever the user types a new query.
67
+ useEffect(() => {
68
+ setDismissed(false)
69
+ }, [query])
70
+
71
+ if (dismissed) return null
72
+ if (!results || results.length === 0) return null
73
+
74
+ return (
75
+ <div
76
+ role="region"
77
+ aria-label="Articles suggérés"
78
+ className="animate-in slide-in-from-top-2 fade-in mt-3 rounded-xl border border-blue-100 bg-blue-50/60 p-4 duration-300 dark:border-blue-900/40 dark:bg-blue-950/30"
79
+ >
80
+ <div className="mb-3 flex items-start justify-between gap-3">
81
+ <p className="text-sm font-semibold text-blue-700 dark:text-blue-300">
82
+ 💡 Ces articles peuvent répondre à votre question
83
+ </p>
84
+ <button
85
+ type="button"
86
+ onClick={() => setDismissed(true)}
87
+ className="text-[11px] font-medium text-blue-600/70 hover:text-blue-700 dark:text-blue-300/70 dark:hover:text-blue-200"
88
+ >
89
+ Ignorer
90
+ </button>
91
+ </div>
92
+
93
+ <ul className="space-y-2">
94
+ {results.map((r) => (
95
+ <li
96
+ key={r.id}
97
+ className="rounded-lg border border-blue-100 bg-white p-3 transition-colors hover:border-blue-300 hover:bg-blue-50/40 dark:border-blue-900/50 dark:bg-slate-900/40 dark:hover:border-blue-700"
98
+ >
99
+ <div className="flex items-start justify-between gap-3">
100
+ <div className="min-w-0 flex-1">
101
+ <h4 className="truncate text-sm font-semibold text-slate-800 dark:text-slate-100">
102
+ {r.title}
103
+ </h4>
104
+ {r.excerpt && (
105
+ <p className="mt-1 line-clamp-2 text-xs leading-relaxed text-slate-500 dark:text-slate-400">
106
+ {r.excerpt}
107
+ </p>
108
+ )}
109
+ </div>
110
+ <a
111
+ href={`/support/kb/${r.slug}`}
112
+ target="_blank"
113
+ rel="noopener noreferrer"
114
+ className="flex-shrink-0 rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition hover:bg-blue-700"
115
+ >
116
+ Lire l&apos;article
117
+ </a>
118
+ </div>
119
+ </li>
120
+ ))}
121
+ </ul>
122
+
123
+ <p className="mt-3 text-[11px] text-slate-500 dark:text-slate-400">
124
+ Mon problème n&apos;est pas résolu — continuez à remplir le formulaire pour créer un ticket.
125
+ </p>
126
+ </div>
127
+ )
128
+ }
@@ -2,13 +2,14 @@
2
2
 
3
3
  import React, { useState, useEffect, useRef, useCallback } from 'react'
4
4
  import { useRouter } from 'next/navigation'
5
+ import KbDeflection from './KbDeflection'
5
6
 
6
7
  const categories = [
7
- { label: 'Bug / Dysfonctionnement', value: 'bug', icon: '🐛' },
8
- { label: 'Modification de contenu', value: 'content', icon: '📝' },
9
- { label: 'Nouvelle fonctionnalité', value: 'feature', icon: '✨' },
10
- { label: 'Question / Aide', value: 'question', icon: '💬' },
11
- { label: 'Hébergement / Domaine', value: 'hosting', icon: '🌐' },
8
+ { label: 'Bug / Dysfonctionnement', shortLabel: 'Bug', desc: 'Quelque chose ne fonctionne pas', value: 'bug', icon: '🐛' },
9
+ { label: 'Modification de contenu', shortLabel: 'Contenu', desc: 'Texte, image, page', value: 'content', icon: '📝' },
10
+ { label: 'Nouvelle fonctionnalité', shortLabel: 'Feature', desc: 'Idee, evolution', value: 'feature', icon: '✨' },
11
+ { label: 'Question / Aide', shortLabel: 'Question', desc: 'Information, conseil', value: 'question', icon: '💬' },
12
+ { label: 'Hébergement / Domaine', shortLabel: 'Hebergement', desc: 'DNS, mail, performance', value: 'hosting', icon: '🌐' },
12
13
  ]
13
14
 
14
15
  const templates: Record<string, { subject: string; message: string }> = {
@@ -30,13 +31,49 @@ const templates: Record<string, { subject: string; message: string }> = {
30
31
  },
31
32
  }
32
33
 
34
+ // SLA fallback values (hours) used when no sla-policies record matches.
35
+ // Aligned with the brief: urgent=2h, high=4h, normal=24h, low=48h.
36
+ const SLA_FALLBACK_HOURS: Record<string, number> = {
37
+ urgent: 2,
38
+ high: 4,
39
+ normal: 24,
40
+ low: 48,
41
+ }
42
+
33
43
  const priorities = [
34
- { label: 'Basse', value: 'low', color: 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300' },
35
- { label: 'Normale', value: 'normal', color: 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400' },
36
- { label: 'Haute', value: 'high', color: 'bg-amber-50 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400' },
37
- { label: 'Urgente', value: 'urgent', color: 'bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400' },
44
+ {
45
+ label: 'Pas urgent',
46
+ value: 'low',
47
+ desc: 'Peut attendre quelques jours',
48
+ color: 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300',
49
+ },
50
+ {
51
+ label: 'Normal',
52
+ value: 'normal',
53
+ desc: 'Demande standard',
54
+ color: 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400',
55
+ },
56
+ {
57
+ label: 'Important',
58
+ value: 'high',
59
+ desc: 'Genere une gene quotidienne',
60
+ color: 'bg-amber-50 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400',
61
+ },
62
+ {
63
+ label: 'Urgent',
64
+ value: 'urgent',
65
+ desc: 'Bloque mon activite',
66
+ color: 'bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400',
67
+ },
38
68
  ]
39
69
 
70
+ function formatSla(hours: number): string {
71
+ if (hours < 1) return 'moins d\'1h'
72
+ if (hours < 24) return `${hours}h`
73
+ const days = Math.round(hours / 24)
74
+ return days === 1 ? '1 jour' : `${days} jours`
75
+ }
76
+
40
77
  interface Project {
41
78
  id: number | string
42
79
  name: string
@@ -58,7 +95,8 @@ export default function NewTicketPage() {
58
95
  const [error, setError] = useState('')
59
96
  const [loading, setLoading] = useState(false)
60
97
  const [isDragging, setIsDragging] = useState(false)
61
- const [showAdvanced, setShowAdvanced] = useState(false)
98
+ // Map of priority → SLA in hours, loaded from sla-policies when available.
99
+ const [slaByPriority, setSlaByPriority] = useState<Record<string, number>>(SLA_FALLBACK_HOURS)
62
100
 
63
101
  useEffect(() => {
64
102
  const fetchProjects = async () => {
@@ -75,6 +113,32 @@ export default function NewTicketPage() {
75
113
  fetchProjects()
76
114
  }, [])
77
115
 
116
+ // Load SLA policies — try to read first-response targets and override fallback per priority.
117
+ // Endpoint may not be public; on failure we just keep the fallback.
118
+ useEffect(() => {
119
+ const fetchSla = async () => {
120
+ try {
121
+ const res = await fetch('/api/sla-policies?limit=10&depth=0', { credentials: 'include' })
122
+ if (!res.ok) return
123
+ const data = await res.json()
124
+ const next: Record<string, number> = { ...SLA_FALLBACK_HOURS }
125
+ for (const policy of data.docs || []) {
126
+ // SLA policy shape may vary — try common field names defensively.
127
+ const prio = (policy.priority || policy.level || '').toLowerCase()
128
+ const responseHours: number | undefined =
129
+ policy.firstResponseHours ??
130
+ policy.responseTimeHours ??
131
+ (typeof policy.firstResponseMinutes === 'number' ? policy.firstResponseMinutes / 60 : undefined)
132
+ if (prio && typeof responseHours === 'number' && responseHours > 0) {
133
+ next[prio] = responseHours
134
+ }
135
+ }
136
+ setSlaByPriority(next)
137
+ } catch { /* keep fallback */ }
138
+ }
139
+ fetchSla()
140
+ }, [])
141
+
78
142
  const addFiles = useCallback((newFiles: File[]) => {
79
143
  const tooLarge = newFiles.filter((f) => f.size > MAX_FILE_SIZE)
80
144
  if (tooLarge.length > 0) {
@@ -273,6 +337,8 @@ export default function NewTicketPage() {
273
337
  placeholder="Ex: Erreur 404 sur la page contact"
274
338
  className="w-full rounded-xl border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-slate-900/50 px-4 py-3 text-sm text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 outline-none transition-all duration-200 focus:border-blue-500 focus:bg-white dark:focus:bg-slate-900 focus:ring-4 focus:ring-blue-500/10"
275
339
  />
340
+ {/* KB deflection — suggest existing articles before ticket creation */}
341
+ <KbDeflection query={subject} limit={3} />
276
342
  </div>
277
343
 
278
344
  {/* Description */}
@@ -380,102 +446,139 @@ export default function NewTicketPage() {
380
446
  )}
381
447
  </div>
382
448
 
383
- {/* Advanced options toggle */}
384
- <div className="border-t border-slate-100 dark:border-slate-700/50 pt-5">
385
- <button
386
- type="button"
387
- onClick={() => setShowAdvanced(!showAdvanced)}
388
- className="group flex w-full items-center justify-between rounded-lg px-1 py-1 text-sm font-medium text-slate-500 dark:text-slate-400 transition-colors hover:text-slate-700 dark:hover:text-slate-200"
449
+ {/* Category radio cards in a responsive grid (no longer hidden behind a toggle) */}
450
+ <div className="mb-6">
451
+ <label className="mb-2 block text-sm font-semibold text-slate-700 dark:text-slate-200">
452
+ Categorie
453
+ </label>
454
+ <div
455
+ role="radiogroup"
456
+ aria-label="Categorie du ticket"
457
+ className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5"
389
458
  >
390
- <span className="flex items-center gap-2">
391
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4">
392
- <path d="M17 2.75a.75.75 0 00-1.5 0v5.5a.75.75 0 001.5 0v-5.5zM17 15.75a.75.75 0 00-1.5 0v1.5a.75.75 0 001.5 0v-1.5zM3.75 15a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5a.75.75 0 01.75-.75zM4.5 2.75a.75.75 0 00-1.5 0v5.5a.75.75 0 001.5 0v-5.5zM10 11a.75.75 0 01.75.75v5.5a.75.75 0 01-1.5 0v-5.5A.75.75 0 0110 11zM10.75 2.75a.75.75 0 00-1.5 0v1.5a.75.75 0 001.5 0v-1.5zM10 6a2 2 0 100 4 2 2 0 000-4zM3.75 10a2 2 0 100 4 2 2 0 000-4zM16.25 10a2 2 0 100 4 2 2 0 000-4z" />
393
- </svg>
394
- Options avancees
395
- </span>
396
- <svg
397
- xmlns="http://www.w3.org/2000/svg"
398
- viewBox="0 0 20 20"
399
- fill="currentColor"
400
- className={`h-4 w-4 transition-transform duration-200 ${showAdvanced ? 'rotate-180' : ''}`}
401
- >
402
- <path fillRule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clipRule="evenodd" />
403
- </svg>
404
- </button>
405
-
406
- {showAdvanced && (
407
- <div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-3 rounded-xl border border-slate-100 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/30 p-5">
408
- {/* Category */}
409
- <div>
410
- <label htmlFor="category" className="mb-2 block text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
411
- Categorie
412
- </label>
413
- <select
414
- id="category"
415
- value={category}
416
- onChange={(e) => {
417
- const val = e.target.value
418
- setCategory(val)
419
- const tmpl = templates[val]
420
- if (tmpl) {
421
- if (!message.trim()) setMessage(tmpl.message)
422
- if (!subject.trim() && tmpl.subject) setSubject(tmpl.subject)
423
- }
424
- }}
425
- className="w-full rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 px-3 py-2.5 text-sm text-slate-900 dark:text-white outline-none transition-all focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10"
459
+ {categories.map((cat) => {
460
+ const isActive = category === cat.value
461
+ return (
462
+ <label
463
+ key={cat.value}
464
+ className={`group relative flex cursor-pointer flex-col items-start gap-1 rounded-xl border p-3 text-left transition-all ${
465
+ isActive
466
+ ? 'border-blue-500 bg-blue-50 ring-2 ring-blue-500/20 dark:border-blue-400 dark:bg-blue-950/40'
467
+ : 'border-slate-200 bg-white hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800/50 dark:hover:border-slate-600 dark:hover:bg-slate-800'
468
+ }`}
426
469
  >
427
- <option value="">Selectionner</option>
428
- {categories.map((cat) => (
429
- <option key={cat.value} value={cat.value}>
430
- {cat.icon} {cat.label}
431
- </option>
432
- ))}
433
- </select>
434
- </div>
435
-
436
- {/* Priority */}
437
- <div>
438
- <label htmlFor="priority" className="mb-2 block text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
439
- Priorite
470
+ <input
471
+ type="radio"
472
+ name="category"
473
+ value={cat.value}
474
+ checked={isActive}
475
+ onChange={() => {
476
+ setCategory(cat.value)
477
+ const tmpl = templates[cat.value]
478
+ if (tmpl) {
479
+ if (!message.trim()) setMessage(tmpl.message)
480
+ if (!subject.trim() && tmpl.subject) setSubject(tmpl.subject)
481
+ }
482
+ }}
483
+ className="sr-only"
484
+ />
485
+ <span className="text-xl" aria-hidden>{cat.icon}</span>
486
+ <span className={`text-sm font-semibold ${
487
+ isActive ? 'text-blue-700 dark:text-blue-300' : 'text-slate-800 dark:text-slate-200'
488
+ }`}>
489
+ {cat.shortLabel}
490
+ </span>
491
+ <span className="text-xs text-slate-500 dark:text-slate-400">
492
+ {cat.desc}
493
+ </span>
440
494
  </label>
441
- <select
442
- id="priority"
443
- value={priority}
444
- onChange={(e) => setPriority(e.target.value)}
445
- className="w-full rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 px-3 py-2.5 text-sm text-slate-900 dark:text-white outline-none transition-all focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10"
495
+ )
496
+ })}
497
+ </div>
498
+ </div>
499
+
500
+ {/* Priority — radio cards with dynamic SLA per level */}
501
+ <div className="mb-6">
502
+ <label className="mb-2 block text-sm font-semibold text-slate-700 dark:text-slate-200">
503
+ Niveau d&apos;urgence
504
+ </label>
505
+ <div role="radiogroup" aria-label="Niveau d'urgence" className="grid grid-cols-1 gap-2 sm:grid-cols-2">
506
+ {priorities.map((p) => {
507
+ const isActive = priority === p.value
508
+ const slaHours = slaByPriority[p.value] ?? SLA_FALLBACK_HOURS[p.value]
509
+ return (
510
+ <label
511
+ key={p.value}
512
+ className={`flex cursor-pointer items-start gap-3 rounded-xl border p-3 transition-all ${
513
+ isActive
514
+ ? 'border-blue-500 bg-blue-50 ring-2 ring-blue-500/20 dark:border-blue-400 dark:bg-blue-950/40'
515
+ : 'border-slate-200 bg-white hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800/50 dark:hover:border-slate-600 dark:hover:bg-slate-800'
516
+ }`}
446
517
  >
447
- {priorities.map((p) => (
448
- <option key={p.value} value={p.value}>
449
- {p.label}
450
- </option>
451
- ))}
452
- </select>
453
- </div>
454
-
455
- {/* Project */}
456
- {projects.length > 0 && (
457
- <div>
458
- <label htmlFor="project" className="mb-2 block text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
459
- Projet
460
- </label>
461
- <select
462
- id="project"
463
- value={project}
464
- onChange={(e) => setProject(e.target.value)}
465
- className="w-full rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 px-3 py-2.5 text-sm text-slate-900 dark:text-white outline-none transition-all focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10"
518
+ <input
519
+ type="radio"
520
+ name="priority"
521
+ value={p.value}
522
+ checked={isActive}
523
+ onChange={() => setPriority(p.value)}
524
+ className="sr-only"
525
+ />
526
+ <span
527
+ aria-hidden
528
+ className={`mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border-2 ${
529
+ isActive
530
+ ? 'border-blue-500 dark:border-blue-400'
531
+ : 'border-slate-300 dark:border-slate-600'
532
+ }`}
466
533
  >
467
- <option value="">Selectionner</option>
468
- {projects.map((p) => (
469
- <option key={p.id} value={p.id}>
470
- {p.name}
471
- </option>
472
- ))}
473
- </select>
474
- </div>
475
- )}
476
- </div>
477
- )}
534
+ {isActive && <span className="h-2 w-2 rounded-full bg-blue-500 dark:bg-blue-400" />}
535
+ </span>
536
+ <span className="min-w-0 flex-1">
537
+ <span className="flex flex-wrap items-baseline justify-between gap-2">
538
+ <span className={`text-sm font-semibold ${
539
+ isActive ? 'text-blue-700 dark:text-blue-300' : 'text-slate-800 dark:text-slate-200'
540
+ }`}>
541
+ {p.label}
542
+ </span>
543
+ <span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${p.color}`}>
544
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-3 w-3" aria-hidden>
545
+ <circle cx="12" cy="12" r="10" />
546
+ <polyline points="12 6 12 12 16 14" />
547
+ </svg>
548
+ Reponse sous {formatSla(slaHours)}
549
+ </span>
550
+ </span>
551
+ <span className="mt-0.5 block text-xs text-slate-500 dark:text-slate-400">
552
+ {p.desc}
553
+ </span>
554
+ </span>
555
+ </label>
556
+ )
557
+ })}
558
+ </div>
478
559
  </div>
560
+
561
+ {/* Project selector (only shown when host app has projects) */}
562
+ {projects.length > 0 && (
563
+ <div className="mb-2">
564
+ <label htmlFor="project" className="mb-2 block text-sm font-semibold text-slate-700 dark:text-slate-200">
565
+ Projet
566
+ </label>
567
+ <select
568
+ id="project"
569
+ value={project}
570
+ onChange={(e) => setProject(e.target.value)}
571
+ className="w-full rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 px-3 py-2.5 text-sm text-slate-900 dark:text-white outline-none transition-all focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10"
572
+ >
573
+ <option value="">Aucun projet specifique</option>
574
+ {projects.map((p) => (
575
+ <option key={p.id} value={p.id}>
576
+ {p.name}
577
+ </option>
578
+ ))}
579
+ </select>
580
+ </div>
581
+ )}
479
582
  </div>
480
583
 
481
584
  {/* Submit footer */}
@@ -309,6 +309,11 @@
309
309
  "normal": "Normal",
310
310
  "high": "High",
311
311
  "urgent": "Urgent"
312
+ },
313
+ "deflection": {
314
+ "title": "These articles may answer your question",
315
+ "readMore": "Read article",
316
+ "stillCreateTicket": "My issue isn't resolved — create a ticket anyway"
312
317
  }
313
318
  },
314
319
  "profile": {
@@ -309,6 +309,11 @@
309
309
  "normal": "Normale",
310
310
  "high": "Haute",
311
311
  "urgent": "Urgente"
312
+ },
313
+ "deflection": {
314
+ "title": "Ces articles peuvent répondre à votre question",
315
+ "readMore": "Lire l'article",
316
+ "stillCreateTicket": "Mon problème n'est pas résolu — créer un ticket quand même"
312
317
  }
313
318
  },
314
319
  "profile": {