@consilioweb/payload-support 0.9.5 → 0.9.7

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.
package/dist/index.cjs CHANGED
@@ -1457,15 +1457,14 @@ function emailRichContent(html, config) {
1457
1457
  function convertFencedCodeBlocks(input) {
1458
1458
  if (!input.includes("```")) return input;
1459
1459
  const normalized = input.replace(/<br\s*\/?>/gi, "\n").replace(/<\/(p|div)>\s*<(p|div)[^>]*>/gi, "\n").replace(/<(p|div)[^>]*>/gi, "").replace(/<\/(p|div)>/gi, "\n");
1460
- return normalized.replace(/```(\w*)\n?([\s\S]*?)```/g, (_match, lang, code) => {
1461
- const cleanCode = escapeHtml((code || "").replace(/\n$/, ""));
1462
- const label = lang ? lang.trim() : "code";
1463
- return `<div style="margin: 16px 0; border: 1px solid #1f2937; border-radius: 8px; overflow: hidden; background: #0f172a;">
1464
- <div style="padding: 6px 14px; background: #1e293b; border-bottom: 1px solid #334155; font-family: 'SF Mono', 'Fira Code', Consolas, monospace; font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em;">${escapeHtml(label)}</div>
1465
- <pre style="margin: 0; padding: 14px 16px; overflow-x: auto; background: #0f172a; color: #e2e8f0; font-family: 'SF Mono', 'Fira Code', Consolas, monospace; font-size: 13px; line-height: 1.6; white-space: pre;"><code style="background: transparent; padding: 0; color: inherit; font-family: inherit; font-size: inherit;">${cleanCode}</code></pre>
1466
- </div>`;
1467
- }).split(/(<div style="margin: 16px 0[\s\S]*?<\/div>)/g).map((chunk) => {
1468
- if (chunk.startsWith('<div style="margin: 16px 0')) return chunk;
1460
+ const codeBlocks = [];
1461
+ const withMarkers = normalized.replace(/```(\w*)\n?([\s\S]*?)```/g, (_match, lang, code) => {
1462
+ codeBlocks.push(renderCodeBlockHtml(lang || "", code || ""));
1463
+ return `\0CODEBLOCK_${codeBlocks.length - 1}\0`;
1464
+ });
1465
+ return withMarkers.split(/(\x00CODEBLOCK_\d+\x00)/g).map((chunk) => {
1466
+ const markerMatch = chunk.match(/^\x00CODEBLOCK_(\d+)\x00$/);
1467
+ if (markerMatch) return codeBlocks[parseInt(markerMatch[1], 10)];
1469
1468
  return chunk.split("\n").map((line) => line.trim() ? `<p>${line}</p>` : "").join("");
1470
1469
  }).join("");
1471
1470
  }
@@ -1500,9 +1499,40 @@ function emailButton(text, url, color = "primary", config) {
1500
1499
  </div>
1501
1500
  `;
1502
1501
  }
1502
+ function renderCodeBlockHtml(lang, code) {
1503
+ const cleanCode = escapeHtml(code.replace(/\n$/, ""));
1504
+ const label = lang ? lang.trim() : "code";
1505
+ return `<div style="margin: 12px 0; border: 1px solid #1f2937; border-radius: 8px; overflow: hidden; background: #0f172a;">
1506
+ <div style="padding: 6px 14px; background: #1e293b; border-bottom: 1px solid #334155; font-family: 'SF Mono', 'Fira Code', Consolas, monospace; font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em;">${escapeHtml(label)}</div>
1507
+ <pre style="margin: 0; padding: 14px 16px; overflow-x: auto; background: #0f172a; color: #e2e8f0; font-family: 'SF Mono', 'Fira Code', Consolas, monospace; font-size: 13px; line-height: 1.6; white-space: pre;"><code style="background: transparent; padding: 0; color: inherit; font-family: inherit; font-size: inherit;">${cleanCode}</code></pre>
1508
+ </div>`;
1509
+ }
1503
1510
  function emailQuote(content, borderColor, config) {
1504
1511
  const c = resolveConfig(config);
1505
1512
  const color = borderColor || c.brandColor;
1513
+ if (content && content.includes("```")) {
1514
+ const parts = [];
1515
+ const regex = /```(\w*)\n?([\s\S]*?)```/g;
1516
+ let lastIndex = 0;
1517
+ let match;
1518
+ while ((match = regex.exec(content)) !== null) {
1519
+ const textBefore = content.slice(lastIndex, match.index).trim();
1520
+ if (textBefore) {
1521
+ parts.push(`<p style="margin: 0 0 12px 0; font-size: 15px; line-height: 1.75; color: #333333; white-space: pre-wrap;">${escapeHtml(textBefore)}</p>`);
1522
+ }
1523
+ parts.push(renderCodeBlockHtml(match[1] || "", match[2] || ""));
1524
+ lastIndex = match.index + match[0].length;
1525
+ }
1526
+ const textAfter = content.slice(lastIndex).trim();
1527
+ if (textAfter) {
1528
+ parts.push(`<p style="margin: 12px 0 0 0; font-size: 15px; line-height: 1.75; color: #333333; white-space: pre-wrap;">${escapeHtml(textAfter)}</p>`);
1529
+ }
1530
+ return `
1531
+ <div style="margin: 24px 0; padding: 20px 24px; background: #f8f9fa; border-left: 4px solid ${color}; border-radius: 0 8px 8px 0;">
1532
+ ${parts.join("")}
1533
+ </div>
1534
+ `;
1535
+ }
1506
1536
  return `
1507
1537
  <div style="margin: 24px 0; padding: 20px 24px; background: #f8f9fa; border-left: 4px solid ${color}; border-radius: 0 8px 8px 0;">
1508
1538
  <p style="margin: 0; font-size: 15px; line-height: 1.75; color: #333333; white-space: pre-wrap;">${escapeHtml(content)}</p>
package/dist/index.js CHANGED
@@ -1451,15 +1451,14 @@ function emailRichContent(html, config) {
1451
1451
  function convertFencedCodeBlocks(input) {
1452
1452
  if (!input.includes("```")) return input;
1453
1453
  const normalized = input.replace(/<br\s*\/?>/gi, "\n").replace(/<\/(p|div)>\s*<(p|div)[^>]*>/gi, "\n").replace(/<(p|div)[^>]*>/gi, "").replace(/<\/(p|div)>/gi, "\n");
1454
- return normalized.replace(/```(\w*)\n?([\s\S]*?)```/g, (_match, lang, code) => {
1455
- const cleanCode = escapeHtml((code || "").replace(/\n$/, ""));
1456
- const label = lang ? lang.trim() : "code";
1457
- return `<div style="margin: 16px 0; border: 1px solid #1f2937; border-radius: 8px; overflow: hidden; background: #0f172a;">
1458
- <div style="padding: 6px 14px; background: #1e293b; border-bottom: 1px solid #334155; font-family: 'SF Mono', 'Fira Code', Consolas, monospace; font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em;">${escapeHtml(label)}</div>
1459
- <pre style="margin: 0; padding: 14px 16px; overflow-x: auto; background: #0f172a; color: #e2e8f0; font-family: 'SF Mono', 'Fira Code', Consolas, monospace; font-size: 13px; line-height: 1.6; white-space: pre;"><code style="background: transparent; padding: 0; color: inherit; font-family: inherit; font-size: inherit;">${cleanCode}</code></pre>
1460
- </div>`;
1461
- }).split(/(<div style="margin: 16px 0[\s\S]*?<\/div>)/g).map((chunk) => {
1462
- if (chunk.startsWith('<div style="margin: 16px 0')) return chunk;
1454
+ const codeBlocks = [];
1455
+ const withMarkers = normalized.replace(/```(\w*)\n?([\s\S]*?)```/g, (_match, lang, code) => {
1456
+ codeBlocks.push(renderCodeBlockHtml(lang || "", code || ""));
1457
+ return `\0CODEBLOCK_${codeBlocks.length - 1}\0`;
1458
+ });
1459
+ return withMarkers.split(/(\x00CODEBLOCK_\d+\x00)/g).map((chunk) => {
1460
+ const markerMatch = chunk.match(/^\x00CODEBLOCK_(\d+)\x00$/);
1461
+ if (markerMatch) return codeBlocks[parseInt(markerMatch[1], 10)];
1463
1462
  return chunk.split("\n").map((line) => line.trim() ? `<p>${line}</p>` : "").join("");
1464
1463
  }).join("");
1465
1464
  }
@@ -1494,9 +1493,40 @@ function emailButton(text, url, color = "primary", config) {
1494
1493
  </div>
1495
1494
  `;
1496
1495
  }
1496
+ function renderCodeBlockHtml(lang, code) {
1497
+ const cleanCode = escapeHtml(code.replace(/\n$/, ""));
1498
+ const label = lang ? lang.trim() : "code";
1499
+ return `<div style="margin: 12px 0; border: 1px solid #1f2937; border-radius: 8px; overflow: hidden; background: #0f172a;">
1500
+ <div style="padding: 6px 14px; background: #1e293b; border-bottom: 1px solid #334155; font-family: 'SF Mono', 'Fira Code', Consolas, monospace; font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em;">${escapeHtml(label)}</div>
1501
+ <pre style="margin: 0; padding: 14px 16px; overflow-x: auto; background: #0f172a; color: #e2e8f0; font-family: 'SF Mono', 'Fira Code', Consolas, monospace; font-size: 13px; line-height: 1.6; white-space: pre;"><code style="background: transparent; padding: 0; color: inherit; font-family: inherit; font-size: inherit;">${cleanCode}</code></pre>
1502
+ </div>`;
1503
+ }
1497
1504
  function emailQuote(content, borderColor, config) {
1498
1505
  const c = resolveConfig(config);
1499
1506
  const color = borderColor || c.brandColor;
1507
+ if (content && content.includes("```")) {
1508
+ const parts = [];
1509
+ const regex = /```(\w*)\n?([\s\S]*?)```/g;
1510
+ let lastIndex = 0;
1511
+ let match;
1512
+ while ((match = regex.exec(content)) !== null) {
1513
+ const textBefore = content.slice(lastIndex, match.index).trim();
1514
+ if (textBefore) {
1515
+ parts.push(`<p style="margin: 0 0 12px 0; font-size: 15px; line-height: 1.75; color: #333333; white-space: pre-wrap;">${escapeHtml(textBefore)}</p>`);
1516
+ }
1517
+ parts.push(renderCodeBlockHtml(match[1] || "", match[2] || ""));
1518
+ lastIndex = match.index + match[0].length;
1519
+ }
1520
+ const textAfter = content.slice(lastIndex).trim();
1521
+ if (textAfter) {
1522
+ parts.push(`<p style="margin: 12px 0 0 0; font-size: 15px; line-height: 1.75; color: #333333; white-space: pre-wrap;">${escapeHtml(textAfter)}</p>`);
1523
+ }
1524
+ return `
1525
+ <div style="margin: 24px 0; padding: 20px 24px; background: #f8f9fa; border-left: 4px solid ${color}; border-radius: 0 8px 8px 0;">
1526
+ ${parts.join("")}
1527
+ </div>
1528
+ `;
1529
+ }
1500
1530
  return `
1501
1531
  <div style="margin: 24px 0; padding: 20px 24px; background: #f8f9fa; border-left: 4px solid ${color}; border-radius: 0 8px 8px 0;">
1502
1532
  <p style="margin: 0; font-size: 15px; line-height: 1.75; color: #333333; white-space: pre-wrap;">${escapeHtml(content)}</p>
@@ -138,6 +138,7 @@ const TicketDetailClient = () => {
138
138
  const [replyHtml, setReplyHtml] = React.useState("");
139
139
  const [isInternal, setIsInternal] = React.useState(false);
140
140
  const [notifyClient, setNotifyClient] = React.useState(true);
141
+ const [sendAsClient, setSendAsClient] = React.useState(false);
141
142
  const [sending, setSending] = React.useState(false);
142
143
  const [showMenu, setShowMenu] = React.useState(false);
143
144
  const [clientTyping, setClientTyping] = React.useState(false);
@@ -419,12 +420,21 @@ ${uploadedLinks.join("\n")}` : replyBody.trim() || "[Contenu enrichi]";
419
420
  method: "POST",
420
421
  headers: { "Content-Type": "application/json" },
421
422
  credentials: "include",
422
- body: JSON.stringify({ ticket: Number(ticketId), body: finalBody, ...finalHtml ? { bodyHtml: finalHtml } : {}, authorType: "admin", isInternal, skipNotification: isInternal || !notifyClient })
423
+ body: JSON.stringify({
424
+ ticket: Number(ticketId),
425
+ body: finalBody,
426
+ ...finalHtml ? { bodyHtml: finalHtml } : {},
427
+ authorType: sendAsClient ? "client" : "admin",
428
+ ...sendAsClient && client ? { authorClient: client.id } : {},
429
+ isInternal: sendAsClient ? false : isInternal,
430
+ skipNotification: sendAsClient || isInternal || !notifyClient
431
+ })
423
432
  });
424
433
  if (res.ok) {
425
434
  setReplyBody("");
426
435
  setReplyHtml("");
427
436
  setIsInternal(false);
437
+ setSendAsClient(false);
428
438
  setPendingFiles([]);
429
439
  editorRef.current?.clear();
430
440
  fetchAll();
@@ -789,18 +799,39 @@ ${uploadedLinks.join("\n")}` : replyBody.trim() || "[Contenu enrichi]";
789
799
  ] }, i)) }),
790
800
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: s__default.default.composerFooter, children: [
791
801
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: s__default.default.composerOptions, children: [
792
- /* @__PURE__ */ jsxRuntime.jsxs("label", { children: [
793
- /* @__PURE__ */ jsxRuntime.jsx("input", { type: "checkbox", checked: isInternal, onChange: (e) => setIsInternal(e.target.checked) }),
794
- " ",
795
- t("detail.internalNote")
796
- ] }),
797
- /* @__PURE__ */ jsxRuntime.jsxs("label", { children: [
798
- /* @__PURE__ */ jsxRuntime.jsx("input", { type: "checkbox", checked: notifyClient, onChange: (e) => setNotifyClient(e.target.checked), disabled: isInternal }),
799
- " ",
800
- t("detail.notify")
802
+ /* @__PURE__ */ jsxRuntime.jsxs(
803
+ "select",
804
+ {
805
+ value: sendAsClient ? "client" : "admin",
806
+ onChange: (e) => {
807
+ const asClient = e.target.value === "client";
808
+ setSendAsClient(asClient);
809
+ if (asClient) {
810
+ setIsInternal(false);
811
+ setNotifyClient(false);
812
+ }
813
+ },
814
+ style: { fontSize: "12px", padding: "4px 8px", fontWeight: 600, borderRadius: 6, border: "1px solid var(--theme-elevation-200)", background: "var(--theme-elevation-0)", color: "var(--theme-text)" },
815
+ children: [
816
+ /* @__PURE__ */ jsxRuntime.jsx("option", { value: "admin", children: "En tant que : Support" }),
817
+ /* @__PURE__ */ jsxRuntime.jsx("option", { value: "client", children: "En tant que : Client" })
818
+ ]
819
+ }
820
+ ),
821
+ !sendAsClient && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
822
+ /* @__PURE__ */ jsxRuntime.jsxs("label", { children: [
823
+ /* @__PURE__ */ jsxRuntime.jsx("input", { type: "checkbox", checked: isInternal, onChange: (e) => setIsInternal(e.target.checked) }),
824
+ " ",
825
+ t("detail.internalNote")
826
+ ] }),
827
+ /* @__PURE__ */ jsxRuntime.jsxs("label", { children: [
828
+ /* @__PURE__ */ jsxRuntime.jsx("input", { type: "checkbox", checked: notifyClient, onChange: (e) => setNotifyClient(e.target.checked), disabled: isInternal }),
829
+ " ",
830
+ t("detail.notify")
831
+ ] })
801
832
  ] })
802
833
  ] }),
803
- /* @__PURE__ */ jsxRuntime.jsx("button", { className: `${s__default.default.sendBtn} ${isInternal ? s__default.default.sendBtnInternal : ""}`, onClick: handleSend, disabled: sending || !replyBody.trim(), "data-action": "send-reply", children: sending ? t("detail.sending") : isInternal ? t("detail.sendNote") : t("detail.sendReply") })
834
+ /* @__PURE__ */ jsxRuntime.jsx("button", { className: `${s__default.default.sendBtn} ${isInternal ? s__default.default.sendBtnInternal : ""}`, onClick: handleSend, disabled: sending || !replyBody.trim(), "data-action": "send-reply", children: sending ? t("detail.sending") : sendAsClient ? "Ajouter message client" : isInternal ? t("detail.sendNote") : t("detail.sendReply") })
804
835
  ] })
805
836
  ]
806
837
  }
@@ -131,6 +131,7 @@ const TicketDetailClient = () => {
131
131
  const [replyHtml, setReplyHtml] = useState("");
132
132
  const [isInternal, setIsInternal] = useState(false);
133
133
  const [notifyClient, setNotifyClient] = useState(true);
134
+ const [sendAsClient, setSendAsClient] = useState(false);
134
135
  const [sending, setSending] = useState(false);
135
136
  const [showMenu, setShowMenu] = useState(false);
136
137
  const [clientTyping, setClientTyping] = useState(false);
@@ -412,12 +413,21 @@ ${uploadedLinks.join("\n")}` : replyBody.trim() || "[Contenu enrichi]";
412
413
  method: "POST",
413
414
  headers: { "Content-Type": "application/json" },
414
415
  credentials: "include",
415
- body: JSON.stringify({ ticket: Number(ticketId), body: finalBody, ...finalHtml ? { bodyHtml: finalHtml } : {}, authorType: "admin", isInternal, skipNotification: isInternal || !notifyClient })
416
+ body: JSON.stringify({
417
+ ticket: Number(ticketId),
418
+ body: finalBody,
419
+ ...finalHtml ? { bodyHtml: finalHtml } : {},
420
+ authorType: sendAsClient ? "client" : "admin",
421
+ ...sendAsClient && client ? { authorClient: client.id } : {},
422
+ isInternal: sendAsClient ? false : isInternal,
423
+ skipNotification: sendAsClient || isInternal || !notifyClient
424
+ })
416
425
  });
417
426
  if (res.ok) {
418
427
  setReplyBody("");
419
428
  setReplyHtml("");
420
429
  setIsInternal(false);
430
+ setSendAsClient(false);
421
431
  setPendingFiles([]);
422
432
  editorRef.current?.clear();
423
433
  fetchAll();
@@ -782,18 +792,39 @@ ${uploadedLinks.join("\n")}` : replyBody.trim() || "[Contenu enrichi]";
782
792
  ] }, i)) }),
783
793
  /* @__PURE__ */ jsxs("div", { className: s.composerFooter, children: [
784
794
  /* @__PURE__ */ jsxs("div", { className: s.composerOptions, children: [
785
- /* @__PURE__ */ jsxs("label", { children: [
786
- /* @__PURE__ */ jsx("input", { type: "checkbox", checked: isInternal, onChange: (e) => setIsInternal(e.target.checked) }),
787
- " ",
788
- t("detail.internalNote")
789
- ] }),
790
- /* @__PURE__ */ jsxs("label", { children: [
791
- /* @__PURE__ */ jsx("input", { type: "checkbox", checked: notifyClient, onChange: (e) => setNotifyClient(e.target.checked), disabled: isInternal }),
792
- " ",
793
- t("detail.notify")
795
+ /* @__PURE__ */ jsxs(
796
+ "select",
797
+ {
798
+ value: sendAsClient ? "client" : "admin",
799
+ onChange: (e) => {
800
+ const asClient = e.target.value === "client";
801
+ setSendAsClient(asClient);
802
+ if (asClient) {
803
+ setIsInternal(false);
804
+ setNotifyClient(false);
805
+ }
806
+ },
807
+ style: { fontSize: "12px", padding: "4px 8px", fontWeight: 600, borderRadius: 6, border: "1px solid var(--theme-elevation-200)", background: "var(--theme-elevation-0)", color: "var(--theme-text)" },
808
+ children: [
809
+ /* @__PURE__ */ jsx("option", { value: "admin", children: "En tant que : Support" }),
810
+ /* @__PURE__ */ jsx("option", { value: "client", children: "En tant que : Client" })
811
+ ]
812
+ }
813
+ ),
814
+ !sendAsClient && /* @__PURE__ */ jsxs(Fragment, { children: [
815
+ /* @__PURE__ */ jsxs("label", { children: [
816
+ /* @__PURE__ */ jsx("input", { type: "checkbox", checked: isInternal, onChange: (e) => setIsInternal(e.target.checked) }),
817
+ " ",
818
+ t("detail.internalNote")
819
+ ] }),
820
+ /* @__PURE__ */ jsxs("label", { children: [
821
+ /* @__PURE__ */ jsx("input", { type: "checkbox", checked: notifyClient, onChange: (e) => setNotifyClient(e.target.checked), disabled: isInternal }),
822
+ " ",
823
+ t("detail.notify")
824
+ ] })
794
825
  ] })
795
826
  ] }),
796
- /* @__PURE__ */ jsx("button", { className: `${s.sendBtn} ${isInternal ? s.sendBtnInternal : ""}`, onClick: handleSend, disabled: sending || !replyBody.trim(), "data-action": "send-reply", children: sending ? t("detail.sending") : isInternal ? t("detail.sendNote") : t("detail.sendReply") })
827
+ /* @__PURE__ */ jsx("button", { className: `${s.sendBtn} ${isInternal ? s.sendBtnInternal : ""}`, onClick: handleSend, disabled: sending || !replyBody.trim(), "data-action": "send-reply", children: sending ? t("detail.sending") : sendAsClient ? "Ajouter message client" : isInternal ? t("detail.sendNote") : t("detail.sendReply") })
797
828
  ] })
798
829
  ]
799
830
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consilioweb/payload-support",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
4
4
  "description": "Payload CMS plugin — professional support & ticketing system with AI, SLA, time tracking, live chat, and more",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -88,19 +88,19 @@ export function emailRichContent(html: string, config?: EmailTemplateConfig): st
88
88
  .replace(/<(p|div)[^>]*>/gi, '')
89
89
  .replace(/<\/(p|div)>/gi, '\n')
90
90
 
91
- return normalized.replace(/```(\w*)\n?([\s\S]*?)```/g, (_match, lang, code) => {
92
- const cleanCode = escapeHtml((code || '').replace(/\n$/, ''))
93
- const label = lang ? lang.trim() : 'code'
94
- return `<div style="margin: 16px 0; border: 1px solid #1f2937; border-radius: 8px; overflow: hidden; background: #0f172a;">
95
- <div style="padding: 6px 14px; background: #1e293b; border-bottom: 1px solid #334155; font-family: 'SF Mono', 'Fira Code', Consolas, monospace; font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em;">${escapeHtml(label)}</div>
96
- <pre style="margin: 0; padding: 14px 16px; overflow-x: auto; background: #0f172a; color: #e2e8f0; font-family: 'SF Mono', 'Fira Code', Consolas, monospace; font-size: 13px; line-height: 1.6; white-space: pre;"><code style="background: transparent; padding: 0; color: inherit; font-family: inherit; font-size: inherit;">${cleanCode}</code></pre>
97
- </div>`
91
+ // Replace code blocks with markers to protect them from paragraph wrapping
92
+ const codeBlocks: string[] = []
93
+ const withMarkers = normalized.replace(/```(\w*)\n?([\s\S]*?)```/g, (_match, lang, code) => {
94
+ codeBlocks.push(renderCodeBlockHtml(lang || '', code || ''))
95
+ return `\x00CODEBLOCK_${codeBlocks.length - 1}\x00`
98
96
  })
99
- // Re-wrap non-code text chunks in paragraphs (split by our <div> blocks)
100
- .split(/(<div style="margin: 16px 0[\s\S]*?<\/div>)/g)
97
+
98
+ // Wrap non-marker text lines in <p>, then restore code blocks
99
+ return withMarkers
100
+ .split(/(\x00CODEBLOCK_\d+\x00)/g)
101
101
  .map((chunk) => {
102
- if (chunk.startsWith('<div style="margin: 16px 0')) return chunk
103
- // Non-code chunk: wrap text lines in <p>
102
+ const markerMatch = chunk.match(/^\x00CODEBLOCK_(\d+)\x00$/)
103
+ if (markerMatch) return codeBlocks[parseInt(markerMatch[1], 10)]
104
104
  return chunk
105
105
  .split('\n')
106
106
  .map((line) => line.trim() ? `<p>${line}</p>` : '')
@@ -165,11 +165,52 @@ export function emailButton(text: string, url: string, color: ButtonColor = 'pri
165
165
  }
166
166
 
167
167
  /**
168
- * Quote block for message previews
168
+ * Render a single fenced code block as styled email HTML.
169
+ * Email clients don't run JS so we just use a nice static layout.
170
+ */
171
+ function renderCodeBlockHtml(lang: string, code: string): string {
172
+ const cleanCode = escapeHtml(code.replace(/\n$/, ''))
173
+ const label = lang ? lang.trim() : 'code'
174
+ return `<div style="margin: 12px 0; border: 1px solid #1f2937; border-radius: 8px; overflow: hidden; background: #0f172a;">
175
+ <div style="padding: 6px 14px; background: #1e293b; border-bottom: 1px solid #334155; font-family: 'SF Mono', 'Fira Code', Consolas, monospace; font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em;">${escapeHtml(label)}</div>
176
+ <pre style="margin: 0; padding: 14px 16px; overflow-x: auto; background: #0f172a; color: #e2e8f0; font-family: 'SF Mono', 'Fira Code', Consolas, monospace; font-size: 13px; line-height: 1.6; white-space: pre;"><code style="background: transparent; padding: 0; color: inherit; font-family: inherit; font-size: inherit;">${cleanCode}</code></pre>
177
+ </div>`
178
+ }
179
+
180
+ /**
181
+ * Quote block for message previews — supports fenced code blocks.
169
182
  */
170
183
  export function emailQuote(content: string, borderColor?: string, config?: EmailTemplateConfig): string {
171
184
  const c = resolveConfig(config)
172
185
  const color = borderColor || c.brandColor
186
+
187
+ // If content has fenced code blocks, render them as styled blocks
188
+ if (content && content.includes('```')) {
189
+ const parts: string[] = []
190
+ const regex = /```(\w*)\n?([\s\S]*?)```/g
191
+ let lastIndex = 0
192
+ let match: RegExpExecArray | null
193
+ while ((match = regex.exec(content)) !== null) {
194
+ // Text before the code block
195
+ const textBefore = content.slice(lastIndex, match.index).trim()
196
+ if (textBefore) {
197
+ parts.push(`<p style="margin: 0 0 12px 0; font-size: 15px; line-height: 1.75; color: #333333; white-space: pre-wrap;">${escapeHtml(textBefore)}</p>`)
198
+ }
199
+ parts.push(renderCodeBlockHtml(match[1] || '', match[2] || ''))
200
+ lastIndex = match.index + match[0].length
201
+ }
202
+ // Remaining text after the last code block
203
+ const textAfter = content.slice(lastIndex).trim()
204
+ if (textAfter) {
205
+ parts.push(`<p style="margin: 12px 0 0 0; font-size: 15px; line-height: 1.75; color: #333333; white-space: pre-wrap;">${escapeHtml(textAfter)}</p>`)
206
+ }
207
+ return `
208
+ <div style="margin: 24px 0; padding: 20px 24px; background: #f8f9fa; border-left: 4px solid ${color}; border-radius: 0 8px 8px 0;">
209
+ ${parts.join('')}
210
+ </div>
211
+ `
212
+ }
213
+
173
214
  return `
174
215
  <div style="margin: 24px 0; padding: 20px 24px; background: #f8f9fa; border-left: 4px solid ${color}; border-radius: 0 8px 8px 0;">
175
216
  <p style="margin: 0; font-size: 15px; line-height: 1.75; color: #333333; white-space: pre-wrap;">${escapeHtml(content)}</p>
@@ -132,6 +132,7 @@ export const TicketDetailClient: React.FC = () => {
132
132
  const [replyHtml, setReplyHtml] = useState('')
133
133
  const [isInternal, setIsInternal] = useState(false)
134
134
  const [notifyClient, setNotifyClient] = useState(true)
135
+ const [sendAsClient, setSendAsClient] = useState(false)
135
136
  const [sending, setSending] = useState(false)
136
137
 
137
138
  const [showMenu, setShowMenu] = useState(false)
@@ -396,8 +397,16 @@ const [clientTyping, setClientTyping] = useState(false)
396
397
  const finalHtml = uploadedLinks.length > 0 ? `${replyHtml || replyBody.trim()}<br/><br/>${uploadedLinks.map((l) => l.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>')).join('<br/>')}` : (replyHtml || undefined)
397
398
 
398
399
  const res = await fetch('/api/ticket-messages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
399
- body: JSON.stringify({ ticket: Number(ticketId), body: finalBody, ...(finalHtml ? { bodyHtml: finalHtml } : {}), authorType: 'admin', isInternal, skipNotification: isInternal || !notifyClient }) })
400
- if (res.ok) { setReplyBody(''); setReplyHtml(''); setIsInternal(false); setPendingFiles([]); editorRef.current?.clear(); fetchAll() }
400
+ body: JSON.stringify({
401
+ ticket: Number(ticketId),
402
+ body: finalBody,
403
+ ...(finalHtml ? { bodyHtml: finalHtml } : {}),
404
+ authorType: sendAsClient ? 'client' : 'admin',
405
+ ...(sendAsClient && client ? { authorClient: client.id } : {}),
406
+ isInternal: sendAsClient ? false : isInternal,
407
+ skipNotification: sendAsClient || isInternal || !notifyClient,
408
+ }) })
409
+ if (res.ok) { setReplyBody(''); setReplyHtml(''); setIsInternal(false); setSendAsClient(false); setPendingFiles([]); editorRef.current?.clear(); fetchAll() }
401
410
  } catch {} finally { setSending(false) }
402
411
  }
403
412
 
@@ -718,13 +727,27 @@ const [clientTyping, setClientTyping] = useState(false)
718
727
  )}
719
728
  <div className={s.composerFooter}>
720
729
  <div className={s.composerOptions}>
721
- {/* #9 — "Internal" -> "Note interne", "Notify" -> "Notifier" */}
722
- <label><input type="checkbox" checked={isInternal} onChange={(e) => setIsInternal(e.target.checked)} /> {t('detail.internalNote')}</label>
723
- <label><input type="checkbox" checked={notifyClient} onChange={(e) => setNotifyClient(e.target.checked)} disabled={isInternal} /> {t('detail.notify')}</label>
730
+ <select
731
+ value={sendAsClient ? 'client' : 'admin'}
732
+ onChange={(e) => {
733
+ const asClient = e.target.value === 'client'
734
+ setSendAsClient(asClient)
735
+ if (asClient) { setIsInternal(false); setNotifyClient(false) }
736
+ }}
737
+ style={{ fontSize: '12px', padding: '4px 8px', fontWeight: 600, borderRadius: 6, border: '1px solid var(--theme-elevation-200)', background: 'var(--theme-elevation-0)', color: 'var(--theme-text)' }}
738
+ >
739
+ <option value="admin">En tant que : Support</option>
740
+ <option value="client">En tant que : Client</option>
741
+ </select>
742
+ {!sendAsClient && (
743
+ <>
744
+ <label><input type="checkbox" checked={isInternal} onChange={(e) => setIsInternal(e.target.checked)} /> {t('detail.internalNote')}</label>
745
+ <label><input type="checkbox" checked={notifyClient} onChange={(e) => setNotifyClient(e.target.checked)} disabled={isInternal} /> {t('detail.notify')}</label>
746
+ </>
747
+ )}
724
748
  </div>
725
- {/* #9 — "Send ->" -> "Envoyer ->" */}
726
749
  <button className={`${s.sendBtn} ${isInternal ? s.sendBtnInternal : ''}`} onClick={handleSend} disabled={sending || !replyBody.trim()} data-action="send-reply">
727
- {sending ? t('detail.sending') : isInternal ? t('detail.sendNote') : t('detail.sendReply')}
750
+ {sending ? t('detail.sending') : sendAsClient ? 'Ajouter message client' : isInternal ? t('detail.sendNote') : t('detail.sendReply')}
728
751
  </button>
729
752
  </div>
730
753
  </div>