@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: crypto.randomUUID()
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
- await client.createReply(ticketId, { body, clientMessageId: crypto.randomUUID() });
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catandbox/schrodinger-web-adapter",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",