@catandbox/schrodinger-web-adapter 0.1.12 → 0.1.13
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.
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { SupportApiClient } from "@catandbox/schrodinger-shopify-adapter/client";
|
|
2
|
+
interface SelectedFile {
|
|
3
|
+
file: File;
|
|
4
|
+
id: string;
|
|
5
|
+
}
|
|
6
|
+
interface FileUploadState {
|
|
7
|
+
files: SelectedFile[];
|
|
8
|
+
addFiles(fileList: FileList): string | null;
|
|
9
|
+
removeFile(id: string): void;
|
|
10
|
+
render(): string;
|
|
11
|
+
attachListeners(container: HTMLElement): void;
|
|
12
|
+
hasFiles(): boolean;
|
|
13
|
+
uploadAll(client: SupportApiClient, ticketId: string, messageId?: string | null): Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
export declare function createFileUpload(): FileUploadState;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
const MAX_FILES = 5;
|
|
2
|
+
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
|
|
3
|
+
async function computeSha256(file) {
|
|
4
|
+
const buffer = await file.arrayBuffer();
|
|
5
|
+
const hash = await crypto.subtle.digest("SHA-256", buffer);
|
|
6
|
+
return Array.from(new Uint8Array(hash))
|
|
7
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
8
|
+
.join("");
|
|
9
|
+
}
|
|
10
|
+
function formatFileSize(bytes) {
|
|
11
|
+
if (bytes < 1024)
|
|
12
|
+
return `${bytes} B`;
|
|
13
|
+
if (bytes < 1024 * 1024)
|
|
14
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
15
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
16
|
+
}
|
|
17
|
+
export function createFileUpload() {
|
|
18
|
+
const files = [];
|
|
19
|
+
function addFiles(fileList) {
|
|
20
|
+
for (const file of Array.from(fileList)) {
|
|
21
|
+
if (files.length >= MAX_FILES) {
|
|
22
|
+
return `Maximum ${MAX_FILES} files allowed.`;
|
|
23
|
+
}
|
|
24
|
+
if (file.size > MAX_FILE_SIZE) {
|
|
25
|
+
return `"${file.name}" exceeds 5 MB limit.`;
|
|
26
|
+
}
|
|
27
|
+
files.push({ file, id: crypto.randomUUID() });
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
function removeFile(id) {
|
|
32
|
+
const idx = files.findIndex((f) => f.id === id);
|
|
33
|
+
if (idx !== -1)
|
|
34
|
+
files.splice(idx, 1);
|
|
35
|
+
}
|
|
36
|
+
function render() {
|
|
37
|
+
return `
|
|
38
|
+
<div>
|
|
39
|
+
<label style="display:block; font-size:13px; font-weight:600; margin-bottom:6px; color:var(--p-color-text, #202223);">
|
|
40
|
+
Attachments <span style="font-weight:400; color:var(--p-color-text-subdued, #6d7175);">(optional, up to ${MAX_FILES} files, 5 MB each)</span>
|
|
41
|
+
</label>
|
|
42
|
+
<div id="sch-file-list" style="display:flex; flex-direction:column; gap:6px; margin-bottom:8px;">
|
|
43
|
+
${files
|
|
44
|
+
.map((f) => `
|
|
45
|
+
<div style="display:flex; align-items:center; gap:8px; padding:6px 10px; background:var(--p-color-bg-surface-secondary, #f6f6f7); border-radius:6px; font-size:13px;" data-file-id="${f.id}">
|
|
46
|
+
<svg width="14" height="14" viewBox="0 0 20 20" fill="var(--p-color-icon-subdued, #8c9196)"><path d="M12 4a1 1 0 0 1 1 1v1h2a1 1 0 0 1 1 1v9a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a1 1 0 0 1 1-1h2V5a1 1 0 0 1 1-1h4zm-1 2H9v1h2V6z"/></svg>
|
|
47
|
+
<span style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${escapeHtml(f.file.name)}</span>
|
|
48
|
+
<span style="color:var(--p-color-text-subdued, #6d7175); flex-shrink:0;">${formatFileSize(f.file.size)}</span>
|
|
49
|
+
<button type="button" class="sch-remove-file" data-file-id="${f.id}"
|
|
50
|
+
style="background:none; border:none; cursor:pointer; padding:2px; color:var(--p-color-icon-subdued, #8c9196); line-height:0;"
|
|
51
|
+
title="Remove">
|
|
52
|
+
<svg width="14" height="14" viewBox="0 0 20 20" fill="currentColor"><path d="M6.707 5.293a1 1 0 0 0-1.414 1.414L8.586 10l-3.293 3.293a1 1 0 1 0 1.414 1.414L10 11.414l3.293 3.293a1 1 0 0 0 1.414-1.414L11.414 10l3.293-3.293a1 1 0 0 0-1.414-1.414L10 8.586 6.707 5.293z"/></svg>
|
|
53
|
+
</button>
|
|
54
|
+
</div>
|
|
55
|
+
`)
|
|
56
|
+
.join("")}
|
|
57
|
+
</div>
|
|
58
|
+
${files.length < MAX_FILES
|
|
59
|
+
? `
|
|
60
|
+
<label for="sch-file-input" style="display:inline-flex; align-items:center; gap:6px; padding:6px 12px; border:1px dashed var(--p-color-border, #c9cccf); border-radius:8px; cursor:pointer; font-size:13px; color:var(--p-color-text-subdued, #6d7175); transition:border-color 0.15s ease, color 0.15s ease;"
|
|
61
|
+
onmouseover="this.style.borderColor='var(--p-color-border-focus, #2c6ecb)'; this.style.color='var(--p-color-text, #202223)'"
|
|
62
|
+
onmouseout="this.style.borderColor='var(--p-color-border, #c9cccf)'; this.style.color='var(--p-color-text-subdued, #6d7175)'"
|
|
63
|
+
>
|
|
64
|
+
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor"><path d="M9 12V7.414l-2.293 2.293a1 1 0 0 1-1.414-1.414l4-4a1 1 0 0 1 1.414 0l4 4a1 1 0 0 1-1.414 1.414L11 7.414V12a1 1 0 1 1-2 0z"/><path d="M4 14a1 1 0 0 1 1 1v1h10v-1a1 1 0 1 1 2 0v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1a1 1 0 0 1 1-1z"/></svg>
|
|
65
|
+
Add files
|
|
66
|
+
</label>
|
|
67
|
+
<input type="file" id="sch-file-input" multiple style="display:none;" />`
|
|
68
|
+
: ""}
|
|
69
|
+
<div id="sch-file-error" style="margin-top:6px;"></div>
|
|
70
|
+
<div id="sch-upload-progress" style="margin-top:6px; display:none;">
|
|
71
|
+
<div style="display:flex; align-items:center; gap:8px;">
|
|
72
|
+
<s-spinner size="small"></s-spinner>
|
|
73
|
+
<s-text variant="bodySm" tone="subdued" id="sch-upload-status">Uploading files...</s-text>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
`;
|
|
78
|
+
}
|
|
79
|
+
function attachListeners(container) {
|
|
80
|
+
const input = container.querySelector("#sch-file-input");
|
|
81
|
+
if (input) {
|
|
82
|
+
input.addEventListener("change", () => {
|
|
83
|
+
if (!input.files || input.files.length === 0)
|
|
84
|
+
return;
|
|
85
|
+
const errorEl = container.querySelector("#sch-file-error");
|
|
86
|
+
const err = addFiles(input.files);
|
|
87
|
+
if (err) {
|
|
88
|
+
errorEl.innerHTML = `<s-text variant="bodySm" tone="critical">${escapeHtml(err)}</s-text>`;
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
errorEl.innerHTML = "";
|
|
92
|
+
}
|
|
93
|
+
input.value = "";
|
|
94
|
+
rerender(container);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
container.querySelectorAll(".sch-remove-file").forEach((btn) => {
|
|
98
|
+
btn.addEventListener("click", () => {
|
|
99
|
+
const fileId = btn.dataset.fileId;
|
|
100
|
+
if (fileId) {
|
|
101
|
+
removeFile(fileId);
|
|
102
|
+
rerender(container);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
function rerender(container) {
|
|
108
|
+
const wrapper = container.querySelector("#sch-file-list")?.parentElement;
|
|
109
|
+
if (!wrapper)
|
|
110
|
+
return;
|
|
111
|
+
wrapper.outerHTML = render();
|
|
112
|
+
attachListeners(container);
|
|
113
|
+
}
|
|
114
|
+
function hasFiles() {
|
|
115
|
+
return files.length > 0;
|
|
116
|
+
}
|
|
117
|
+
async function uploadAll(client, ticketId, messageId) {
|
|
118
|
+
if (files.length === 0)
|
|
119
|
+
return;
|
|
120
|
+
const hashes = await Promise.all(files.map(async (f) => ({
|
|
121
|
+
file: f.file,
|
|
122
|
+
sha256: await computeSha256(f.file)
|
|
123
|
+
})));
|
|
124
|
+
const initResult = await client.initUploads({
|
|
125
|
+
ticketId,
|
|
126
|
+
files: hashes.map((h) => ({
|
|
127
|
+
filename: h.file.name,
|
|
128
|
+
mime: h.file.type || "application/octet-stream",
|
|
129
|
+
sizeBytes: h.file.size,
|
|
130
|
+
sha256: h.sha256
|
|
131
|
+
}))
|
|
132
|
+
});
|
|
133
|
+
await Promise.all(initResult.uploads.map(async (upload, index) => {
|
|
134
|
+
const fileData = hashes[index];
|
|
135
|
+
if (fileData) {
|
|
136
|
+
await client.putUpload(upload.putUrl, fileData.file);
|
|
137
|
+
}
|
|
138
|
+
}));
|
|
139
|
+
await client.completeUploads({
|
|
140
|
+
ticketId,
|
|
141
|
+
uploads: initResult.uploads.map((upload, index) => {
|
|
142
|
+
const fileData = hashes[index];
|
|
143
|
+
return {
|
|
144
|
+
uploadId: upload.uploadId,
|
|
145
|
+
sizeBytes: fileData.file.size,
|
|
146
|
+
sha256: fileData.sha256
|
|
147
|
+
};
|
|
148
|
+
}),
|
|
149
|
+
messageId: messageId ?? null
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
files,
|
|
154
|
+
addFiles,
|
|
155
|
+
removeFile,
|
|
156
|
+
render,
|
|
157
|
+
attachListeners,
|
|
158
|
+
hasFiles,
|
|
159
|
+
uploadAll
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function escapeHtml(text) {
|
|
163
|
+
const div = document.createElement("div");
|
|
164
|
+
div.textContent = text;
|
|
165
|
+
return div.innerHTML;
|
|
166
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createFileUpload } from "./file-upload";
|
|
1
2
|
const FIELD_STYLE = [
|
|
2
3
|
"width:100%",
|
|
3
4
|
"padding:10px 12px",
|
|
@@ -16,6 +17,7 @@ export function renderNewTicketForm(container, client, categories, emitter) {
|
|
|
16
17
|
const categoryOptions = categories
|
|
17
18
|
.map((cat) => `<option value="${cat.id}">${escapeHtml(cat.name)}</option>`)
|
|
18
19
|
.join("");
|
|
20
|
+
const fileUpload = createFileUpload();
|
|
19
21
|
container.innerHTML = `
|
|
20
22
|
<s-card>
|
|
21
23
|
<s-box padding="large">
|
|
@@ -67,6 +69,8 @@ export function renderNewTicketForm(container, client, categories, emitter) {
|
|
|
67
69
|
placeholder="Tell us what happened, what you expected, and any steps to reproduce..."></textarea>
|
|
68
70
|
</div>
|
|
69
71
|
|
|
72
|
+
${fileUpload.render()}
|
|
73
|
+
|
|
70
74
|
<div id="sch-form-error"></div>
|
|
71
75
|
|
|
72
76
|
<div style="display:flex; justify-content:flex-end; gap:8px; padding-top:4px;">
|
|
@@ -79,6 +83,7 @@ export function renderNewTicketForm(container, client, categories, emitter) {
|
|
|
79
83
|
</s-box>
|
|
80
84
|
</s-card>
|
|
81
85
|
`;
|
|
86
|
+
fileUpload.attachListeners(container);
|
|
82
87
|
const form = container.querySelector("#sch-new-ticket-form");
|
|
83
88
|
container.querySelector("#sch-cancel-btn")?.addEventListener("click", () => {
|
|
84
89
|
emitter.emit("ticket:cancel", undefined);
|
|
@@ -105,12 +110,24 @@ export function renderNewTicketForm(container, client, categories, emitter) {
|
|
|
105
110
|
const submitBtn = container.querySelector("#sch-submit-btn");
|
|
106
111
|
submitBtn.setAttribute("disabled", "true");
|
|
107
112
|
try {
|
|
113
|
+
const clientMessageId = crypto.randomUUID();
|
|
108
114
|
const ticket = await client.createTicket({
|
|
109
115
|
title,
|
|
110
116
|
body,
|
|
111
117
|
categoryId: categoryId || null,
|
|
112
|
-
clientMessageId
|
|
118
|
+
clientMessageId
|
|
113
119
|
});
|
|
120
|
+
if (fileUpload.hasFiles()) {
|
|
121
|
+
const progressEl = container.querySelector("#sch-upload-progress");
|
|
122
|
+
if (progressEl)
|
|
123
|
+
progressEl.style.display = "block";
|
|
124
|
+
try {
|
|
125
|
+
await fileUpload.uploadAll(client, ticket.id);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Ticket was created but uploads failed — still navigate to detail
|
|
129
|
+
}
|
|
130
|
+
}
|
|
114
131
|
emitter.emit("ticket:created", ticket.id);
|
|
115
132
|
}
|
|
116
133
|
catch (error) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { renderStatusBadge } from "./status-badge";
|
|
2
|
+
import { createFileUpload } from "./file-upload";
|
|
2
3
|
function formatTimestamp(timestamp) {
|
|
3
4
|
const date = new Date(timestamp * 1000);
|
|
4
5
|
const now = new Date();
|
|
@@ -94,6 +95,7 @@ export async function renderTicketDetail(container, client, ticketId, emitter) {
|
|
|
94
95
|
onfocus="this.style.borderColor='var(--p-color-border-focus, #2c6ecb)'; this.style.outline='none'; this.style.boxShadow='0 0 0 1px var(--p-color-border-focus, #2c6ecb)'"
|
|
95
96
|
onblur="this.style.borderColor='var(--p-color-border, #c9cccf)'; this.style.boxShadow='none'"
|
|
96
97
|
placeholder="Write your reply..."></textarea>
|
|
98
|
+
<div id="sch-reply-files"></div>
|
|
97
99
|
<div style="display:flex; justify-content:space-between; align-items:center;">
|
|
98
100
|
<s-button id="sch-close-ticket-btn">Close Ticket</s-button>
|
|
99
101
|
<s-button variant="primary" id="sch-send-reply-btn">Send Reply</s-button>
|
|
@@ -108,6 +110,14 @@ export async function renderTicketDetail(container, client, ticketId, emitter) {
|
|
|
108
110
|
container.querySelector("#sch-back-btn")?.addEventListener("click", () => {
|
|
109
111
|
emitter.emit("ticket:back", undefined);
|
|
110
112
|
});
|
|
113
|
+
// Mount file upload for replies
|
|
114
|
+
let replyFileUpload = null;
|
|
115
|
+
const replyFilesContainer = container.querySelector("#sch-reply-files");
|
|
116
|
+
if (replyFilesContainer) {
|
|
117
|
+
replyFileUpload = createFileUpload();
|
|
118
|
+
replyFilesContainer.innerHTML = replyFileUpload.render();
|
|
119
|
+
replyFileUpload.attachListeners(replyFilesContainer);
|
|
120
|
+
}
|
|
111
121
|
// Send reply
|
|
112
122
|
container.querySelector("#sch-send-reply-btn")?.addEventListener("click", async () => {
|
|
113
123
|
const textarea = container.querySelector("#sch-reply-body");
|
|
@@ -118,7 +128,16 @@ export async function renderTicketDetail(container, client, ticketId, emitter) {
|
|
|
118
128
|
if (btn)
|
|
119
129
|
btn.setAttribute("disabled", "true");
|
|
120
130
|
try {
|
|
121
|
-
|
|
131
|
+
const clientMessageId = crypto.randomUUID();
|
|
132
|
+
const message = await client.createReply(ticketId, { body, clientMessageId });
|
|
133
|
+
if (replyFileUpload?.hasFiles()) {
|
|
134
|
+
try {
|
|
135
|
+
await replyFileUpload.uploadAll(client, ticketId, message.id);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// Reply created but uploads failed — still re-render
|
|
139
|
+
}
|
|
140
|
+
}
|
|
122
141
|
await renderTicketDetail(container, client, ticketId, emitter);
|
|
123
142
|
}
|
|
124
143
|
catch (error) {
|