@catandbox/schrodinger-web-adapter 0.1.28 → 0.1.33

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/README.md ADDED
@@ -0,0 +1,174 @@
1
+ # @catandbox/schrodinger-web-adapter
2
+
3
+ Framework-agnostic web adapter for [Schrodinger](https://github.com/yetone/schrodinger) — an AI-powered headless helpdesk built on Cloudflare Workers.
4
+
5
+ This package provides everything needed to embed a full customer-facing support portal into any web app, plus the server-side Shopify proxy and webhook utilities. **No React dependency** — the UI renders with [Shopify Polaris Web Components](https://shopify.dev/docs/api/app-home/polaris-web-components).
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @catandbox/schrodinger-web-adapter
11
+ ```
12
+
13
+ ## Package exports
14
+
15
+ | Entry point | Contents |
16
+ |---|---|
17
+ | `@catandbox/schrodinger-web-adapter/client` | Portal UI renderer + `SupportApiClient` |
18
+ | `@catandbox/schrodinger-web-adapter/server` | Shopify proxy handler, webhook forwarders, auth utilities |
19
+ | `@catandbox/schrodinger-web-adapter/signer` | HMAC request signing primitives |
20
+
21
+ ---
22
+
23
+ ## Client — portal UI
24
+
25
+ Renders a complete support portal (ticket list → detail → new ticket form) into any `HTMLElement`. Requires Polaris Web Components to be loaded on the page.
26
+
27
+ ```ts
28
+ import { renderSupportPortal } from "@catandbox/schrodinger-web-adapter/client";
29
+
30
+ const container = document.getElementById("support-root")!;
31
+
32
+ await renderSupportPortal(container, {
33
+ basePath: "/support/api", // path where the server proxy is mounted
34
+ // headers: { "Authorization": "Bearer ..." },
35
+ // getHeaders: async () => ({ "Authorization": `Bearer ${await getToken()}` }),
36
+ });
37
+ ```
38
+
39
+ ### Individual renderers
40
+
41
+ You can also render each view independently:
42
+
43
+ ```ts
44
+ import {
45
+ renderTicketList,
46
+ renderTicketDetail,
47
+ renderNewTicketForm,
48
+ EventEmitter,
49
+ } from "@catandbox/schrodinger-web-adapter/client";
50
+
51
+ const emitter = new EventEmitter();
52
+
53
+ await renderTicketList(container, client, emitter);
54
+ // emitter emits "ticket:select", "ticket:create"
55
+
56
+ await renderTicketDetail(container, client, ticketId, emitter);
57
+ // emitter emits "ticket:back", "ticket:closed", "ticket:reopened"
58
+
59
+ await renderNewTicketForm(container, client, categories, emitter);
60
+ // emitter emits "ticket:created", "ticket:cancel"
61
+ ```
62
+
63
+ ### SupportApiClient
64
+
65
+ The API client is re-exported for convenience — see [`@catandbox/schrodinger-contracts`](https://www.npmjs.com/package/@catandbox/schrodinger-contracts) for full documentation.
66
+
67
+ ```ts
68
+ import { SupportApiClient } from "@catandbox/schrodinger-web-adapter/client";
69
+
70
+ const client = new SupportApiClient({ basePath: "/support/api" });
71
+ const { items } = await client.listTickets();
72
+ ```
73
+
74
+ ---
75
+
76
+ ## Server — Shopify proxy
77
+
78
+ The server module provides a ready-made HTTP proxy that sits between your Shopify app's frontend and the Schrodinger API. It verifies the Shopify session token on every request and signs outbound calls with HMAC credentials.
79
+
80
+ ```ts
81
+ import { createShopifyProxyHandler } from "@catandbox/schrodinger-web-adapter/server";
82
+
83
+ const handler = createShopifyProxyHandler({
84
+ schApiBaseUrl: "https://your-schrodinger-api.example.com",
85
+ schAppId: env.SCH_APP_ID,
86
+ schKeyId: env.SCH_KEY_ID,
87
+ schSecret: env.SCH_SECRET,
88
+ shopifyApiKey: env.SHOPIFY_API_KEY,
89
+ shopifyApiSecret: env.SHOPIFY_API_SECRET,
90
+ basePath: "/support/api", // optional, default: "/support/api"
91
+ });
92
+
93
+ // Cloudflare Workers / any fetch-based runtime:
94
+ export default {
95
+ async fetch(request: Request): Promise<Response> {
96
+ return handler(request);
97
+ },
98
+ };
99
+ ```
100
+
101
+ ### GDPR webhook forwarding
102
+
103
+ ```ts
104
+ import { createShopifyWebhookHandlers } from "@catandbox/schrodinger-web-adapter/server";
105
+
106
+ const webhooks = createShopifyWebhookHandlers({
107
+ schApiBaseUrl: "https://your-schrodinger-api.example.com",
108
+ schAdminApiToken: env.SCH_ADMIN_API_TOKEN,
109
+ shopifyApiKey: env.SHOPIFY_API_KEY,
110
+ shopifyApiSecret: env.SHOPIFY_API_SECRET,
111
+ // called on app/uninstalled to revoke portal access:
112
+ disablePortalAccess: async ({ shopDomain }) => { /* ... */ },
113
+ });
114
+
115
+ // Wire up to your router:
116
+ router.post("/webhooks/customers/data_request", webhooks.handleCustomersDataRequestWebhook);
117
+ router.post("/webhooks/customers/redact", webhooks.handleCustomersRedactWebhook);
118
+ router.post("/webhooks/shop/redact", webhooks.handleShopRedactWebhook);
119
+ router.post("/webhooks/app/uninstalled", webhooks.handleAppUninstalledWebhook);
120
+ ```
121
+
122
+ ### Auth utilities
123
+
124
+ ```ts
125
+ import {
126
+ createPrincipalContext,
127
+ verifyShopifySessionToken,
128
+ verifyShopifyWebhookHmac,
129
+ ShopifyAuthError,
130
+ } from "@catandbox/schrodinger-web-adapter/server";
131
+
132
+ // Verify a Shopify session JWT and extract principal info
133
+ const principal = await createPrincipalContext(request, {
134
+ shopifyApiKey: env.SHOPIFY_API_KEY,
135
+ shopifyApiSecret: env.SHOPIFY_API_SECRET,
136
+ });
137
+ // principal.tenantExternalId — shop domain
138
+ // principal.principalExternalId — customer identifier
139
+ ```
140
+
141
+ ---
142
+
143
+ ## Signer — HMAC request signing
144
+
145
+ Low-level primitives for signing requests to the Schrodinger API with `HMAC-SHA256`. Useful when making server-to-server calls outside of the proxy handler.
146
+
147
+ ```ts
148
+ import { signRequest, sha256Hex } from "@catandbox/schrodinger-web-adapter/signer";
149
+
150
+ const headers = await signRequest({
151
+ appId: "my-app",
152
+ keyId: "key-1",
153
+ keySecret: "secret",
154
+ timestamp: Math.floor(Date.now() / 1000),
155
+ nonce: crypto.randomUUID(),
156
+ method: "POST",
157
+ path: "/v1/tickets",
158
+ rawBody: JSON.stringify({ title: "Hello" }),
159
+ });
160
+
161
+ // headers["X-Sch-AppId"], ["X-Sch-KeyId"], ["X-Sch-Timestamp"],
162
+ // ["X-Sch-Nonce"], ["Content-SHA256"], ["X-Sch-Signature"]
163
+ ```
164
+
165
+ ---
166
+
167
+ ## Requirements
168
+
169
+ - **Runtime**: any environment with `fetch`, `crypto.subtle`, and `TextEncoder` (Cloudflare Workers, modern browsers, Node.js ≥ 18)
170
+ - **UI**: Polaris Web Components must be loaded on the page before calling client renderers
171
+
172
+ ## License
173
+
174
+ MIT
@@ -1,4 +1,4 @@
1
- import type { SupportApiClient } from "@catandbox/schrodinger-shopify-adapter/client";
1
+ import type { SupportApiClient } from "@catandbox/schrodinger-contracts";
2
2
  interface SelectedFile {
3
3
  file: File;
4
4
  id: string;
@@ -2,8 +2,8 @@
2
2
  * Client-side exports — Vanilla TS + Polaris Web Components UI.
3
3
  * No React dependency.
4
4
  */
5
- export { SupportApiClient, SupportApiError } from "@catandbox/schrodinger-shopify-adapter/client";
6
- export type { ClientHeadersProvider, ListTicketsParams, SupportCategory, SupportClientOptions, TicketDetailData, UploadCompleteInput, UploadInitResult, UploadInputFile } from "@catandbox/schrodinger-shopify-adapter/client";
5
+ export { SupportApiClient, SupportApiError } from "@catandbox/schrodinger-contracts";
6
+ export type { ClientHeadersProvider, ListTicketsParams, SupportCategory, SupportClientOptions, TicketDetailData, UploadCompleteInput, UploadInitResult, UploadInputFile } from "@catandbox/schrodinger-contracts";
7
7
  export { renderSupportPortal } from "./portal";
8
8
  export type { SupportPortalOptions } from "./portal";
9
9
  export { renderTicketList } from "./ticket-list";
@@ -2,8 +2,7 @@
2
2
  * Client-side exports — Vanilla TS + Polaris Web Components UI.
3
3
  * No React dependency.
4
4
  */
5
- // Re-export the pure-fetch API client from the React adapter
6
- export { SupportApiClient, SupportApiError } from "@catandbox/schrodinger-shopify-adapter/client";
5
+ export { SupportApiClient, SupportApiError } from "@catandbox/schrodinger-contracts";
7
6
  // Vanilla rendering functions
8
7
  export { renderSupportPortal } from "./portal";
9
8
  export { renderTicketList } from "./ticket-list";
@@ -1,4 +1,4 @@
1
- import type { SupportApiClient, SupportCategory } from "@catandbox/schrodinger-shopify-adapter/client";
1
+ import type { SupportApiClient, SupportCategory } from "@catandbox/schrodinger-contracts";
2
2
  import type { EventEmitter } from "./events";
3
3
  export interface NewTicketFormEvents {
4
4
  [key: string]: unknown;
@@ -1,4 +1,4 @@
1
- import type { SupportClientOptions, SupportCategory } from "@catandbox/schrodinger-shopify-adapter/client";
1
+ import type { SupportClientOptions, SupportCategory } from "@catandbox/schrodinger-contracts";
2
2
  export interface SupportPortalOptions extends SupportClientOptions {
3
3
  /** Pre-loaded categories. If omitted, categories are fetched from the API. */
4
4
  categories?: SupportCategory[];
@@ -1,4 +1,4 @@
1
- import { SupportApiClient } from "@catandbox/schrodinger-shopify-adapter/client";
1
+ import { SupportApiClient } from "@catandbox/schrodinger-contracts";
2
2
  import { EventEmitter } from "./events";
3
3
  import { renderTicketList } from "./ticket-list";
4
4
  import { renderTicketDetail } from "./ticket-detail";
@@ -35,6 +35,7 @@ export async function renderSupportPortal(container, options = {}) {
35
35
  </s-card>
36
36
  `;
37
37
  }
38
+ const locale = options.locale;
38
39
  async function navigate(view, ticketId) {
39
40
  currentView = view;
40
41
  if (ticketId !== undefined) {
@@ -44,11 +45,11 @@ export async function renderSupportPortal(container, options = {}) {
44
45
  showLoading();
45
46
  switch (currentView) {
46
47
  case "list":
47
- await renderTicketList(container, client, emitter);
48
+ await renderTicketList(container, client, emitter, locale);
48
49
  break;
49
50
  case "detail":
50
51
  if (selectedTicketId) {
51
- await renderTicketDetail(container, client, selectedTicketId, emitter);
52
+ await renderTicketDetail(container, client, selectedTicketId, emitter, locale);
52
53
  }
53
54
  break;
54
55
  case "create":
@@ -1,9 +1,9 @@
1
1
  const STATUS_CONFIG = {
2
2
  Active: { label: "Active", tone: "info" },
3
- InProgress: { label: "In Progress", tone: "attention" },
3
+ InProgress: { label: "In Progress", tone: "caution" },
4
4
  AwaitingResponse: { label: "Awaiting Response", tone: "warning" },
5
5
  Closed: { label: "Closed", tone: "success" },
6
- Archived: { label: "Archived", tone: "subdued" }
6
+ Archived: { label: "Archived", tone: "neutral" }
7
7
  };
8
8
  export function renderStatusBadge(status) {
9
9
  const config = STATUS_CONFIG[status] ?? { label: status, tone: "subdued" };
@@ -1,4 +1,4 @@
1
- import type { SupportApiClient } from "@catandbox/schrodinger-shopify-adapter/client";
1
+ import type { SupportApiClient } from "@catandbox/schrodinger-contracts";
2
2
  import type { EventEmitter } from "./events";
3
3
  export interface TicketDetailEvents {
4
4
  [key: string]: unknown;
@@ -6,4 +6,4 @@ export interface TicketDetailEvents {
6
6
  "ticket:closed": string;
7
7
  "ticket:reopened": string;
8
8
  }
9
- export declare function renderTicketDetail(container: HTMLElement, client: SupportApiClient, ticketId: string, emitter: EventEmitter<TicketDetailEvents>): Promise<void>;
9
+ export declare function renderTicketDetail(container: HTMLElement, client: SupportApiClient, ticketId: string, emitter: EventEmitter<TicketDetailEvents>, locale?: string): Promise<void>;
@@ -1,22 +1,22 @@
1
1
  import { renderStatusBadge } from "./status-badge";
2
2
  import { createFileUpload } from "./file-upload";
3
3
  import { setInnerHtml, escapeHtml, formatBodyText, renderFormattingField, attachFormattingField } from "./dom-utils";
4
- function formatTimestamp(timestamp) {
4
+ function formatTimestamp(timestamp, locale) {
5
5
  const date = new Date(timestamp * 1000);
6
6
  const now = new Date();
7
7
  const isToday = date.toDateString() === now.toDateString();
8
8
  if (isToday) {
9
- return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
9
+ return date.toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit" });
10
10
  }
11
- return date.toLocaleDateString(undefined, {
11
+ return date.toLocaleDateString(locale, {
12
12
  month: "short",
13
13
  day: "numeric",
14
14
  hour: "numeric",
15
15
  minute: "2-digit"
16
16
  });
17
17
  }
18
- function formatFullDate(timestamp) {
19
- return new Date(timestamp * 1000).toLocaleDateString(undefined, {
18
+ function formatFullDate(timestamp, locale) {
19
+ return new Date(timestamp * 1000).toLocaleDateString(locale, {
20
20
  month: "long",
21
21
  day: "numeric",
22
22
  year: "numeric",
@@ -24,13 +24,13 @@ function formatFullDate(timestamp) {
24
24
  minute: "2-digit"
25
25
  });
26
26
  }
27
- export async function renderTicketDetail(container, client, ticketId, emitter) {
27
+ export async function renderTicketDetail(container, client, ticketId, emitter, locale) {
28
28
  container.innerHTML = `
29
29
  <s-card>
30
30
  <s-box padding="large">
31
- <div style="display:flex; justify-content:center; padding:32px 0;">
32
- <s-spinner size="small"></s-spinner>
33
- </div>
31
+ <s-stack direction="inline" justifyContent="center" padding="large none">
32
+ <s-spinner></s-spinner>
33
+ </s-stack>
34
34
  </s-box>
35
35
  </s-card>
36
36
  `;
@@ -44,89 +44,89 @@ export async function renderTicketDetail(container, client, ticketId, emitter) {
44
44
  const aliasMap = new Map(portalConfig.aliases.map((a) => [a.id, a]));
45
45
  const assignedAlias = ticket.assignedAliasId ? (aliasMap.get(ticket.assignedAliasId) ?? null) : null;
46
46
  setInnerHtml(container, `
47
- <s-card>
48
- <s-box padding="large">
49
- <s-stack gap="large">
50
- <div>
51
- <s-button variant="plain" id="sch-back-btn">
52
- <span style="display:inline-flex; align-items:center; gap:4px;">
53
- <svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor"><path d="M12.707 15.707a1 1 0 0 1-1.414 0l-5-5a1 1 0 0 1 0-1.414l5-5a1 1 0 1 1 1.414 1.414L8.414 10l4.293 4.293a1 1 0 0 1 0 1.414z"/></svg>
54
- All tickets
55
- </span>
56
- </s-button>
57
- </div>
47
+ <s-stack gap="base">
48
+ <s-card>
49
+ <s-box padding="large">
50
+ <s-stack gap="large">
51
+ <div>
52
+ <s-button variant="tertiary" id="sch-back-btn">
53
+ <span style="display:inline-flex; align-items:center; gap:4px;">
54
+ <svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor"><path d="M12.707 15.707a1 1 0 0 1-1.414 0l-5-5a1 1 0 0 1 0-1.414l5-5a1 1 0 1 1 1.414 1.414L8.414 10l4.293 4.293a1 1 0 0 1 0 1.414z"/></svg>
55
+ All tickets
56
+ </span>
57
+ </s-button>
58
+ </div>
58
59
 
59
- <div style="display:flex; justify-content:space-between; align-items:flex-start; gap:16px;">
60
- <div style="min-width:0; flex:1;">
61
- <s-text variant="headingLg">${escapeHtml(ticket.title)}</s-text>
62
- <div style="margin-top:4px; display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
63
- ${renderStatusBadge(ticket.status)}
64
- <s-text variant="bodySm" tone="subdued">#${ticket.id.slice(0, 8)}</s-text>
65
- <s-text variant="bodySm" tone="subdued">&middot;</s-text>
66
- <s-text variant="bodySm" tone="subdued">Created ${formatFullDate(ticket.createdAt)}</s-text>
60
+ <s-stack direction="inline" justifyContent="space-between" alignItems="start" gap="base">
61
+ <div style="min-width:0; flex:1;">
62
+ <s-heading>${escapeHtml(ticket.title)}</s-heading>
63
+ <s-stack direction="inline" alignItems="center" gap="small" style="margin-top:4px; flex-wrap:wrap;">
64
+ ${renderStatusBadge(ticket.status)}
65
+ <s-text tone="neutral">#${ticket.id.slice(0, 8)}</s-text>
66
+ <s-text tone="neutral">&middot;</s-text>
67
+ <s-text tone="neutral">Created ${formatFullDate(ticket.createdAt, locale)}</s-text>
68
+ </s-stack>
67
69
  </div>
68
- </div>
69
- ${!isOpen ? `<s-button id="sch-reopen-ticket-btn" size="slim">Reopen</s-button>` : ""}
70
- </div>
71
- </s-stack>
72
- </s-box>
73
- </s-card>
70
+ ${!isOpen ? `<s-button id="sch-reopen-ticket-btn">Reopen</s-button>` : ""}
71
+ </s-stack>
72
+ </s-stack>
73
+ </s-box>
74
+ </s-card>
74
75
 
75
- ${assignedAlias
76
+ ${assignedAlias
76
77
  ? `
77
- <s-card style="margin-top:16px;">
78
- <s-box padding="base large">
79
- <div style="display:flex; align-items:center; gap:10px;">
80
- <div style="width:36px; height:36px; border-radius:50%; background:var(--p-color-bg-fill-info, #e4f0fe); display:flex; align-items:center; justify-content:center; flex-shrink:0;">
81
- <span style="font-size:13px; font-weight:700; color:var(--p-color-text-info, #2c6ecb);">${escapeHtml(getInitials(assignedAlias.displayName))}</span>
82
- </div>
83
- <div>
84
- <s-text variant="bodySm" fontWeight="semibold">${escapeHtml(assignedAlias.displayName)}</s-text>
85
- <div><s-text variant="bodySm" tone="subdued">${escapeHtml(assignedAlias.department)}</s-text></div>
86
- </div>
87
- </div>
88
- </s-box>
89
- </s-card>`
78
+ <s-card>
79
+ <s-box padding="base large">
80
+ <s-stack direction="inline" alignItems="center" gap="small">
81
+ <s-avatar size="base" initials="${escapeHtml(getInitials(assignedAlias.displayName))}"${assignedAlias.avatarUrl ? ` src="${escapeHtml(assignedAlias.avatarUrl)}"` : ""}></s-avatar>
82
+ <div>
83
+ <s-text type="strong">${escapeHtml(assignedAlias.displayName)}</s-text>
84
+ <div><s-text tone="neutral">${escapeHtml(assignedAlias.department)}</s-text></div>
85
+ </div>
86
+ </s-stack>
87
+ </s-box>
88
+ </s-card>`
90
89
  : ""}
91
90
 
92
- <div id="sch-messages-list" style="margin-top:16px;">
93
- ${messages.length === 0
91
+ <div id="sch-messages-list">
92
+ ${messages.length === 0
94
93
  ? `
95
- <s-card>
96
- <s-box padding="large">
97
- <div style="text-align:center; padding:24px 0;">
98
- <s-text tone="subdued">No messages yet. Our team will respond shortly.</s-text>
99
- </div>
100
- </s-box>
101
- </s-card>`
102
- : `<div style="display:flex; flex-direction:column; gap:2px;">
103
- ${messages.map((msg) => renderMessage(msg, aliasMap.get(msg.authorAliasId ?? "") ?? null)).join("")}
104
- </div>`}
105
- </div>
94
+ <s-card>
95
+ <s-box padding="large">
96
+ <div style="text-align:center; padding:24px 0;">
97
+ <s-text tone="neutral">No messages yet. Our team will respond shortly.</s-text>
98
+ </div>
99
+ </s-box>
100
+ </s-card>`
101
+ : `<s-stack gap="small-100">
102
+ ${messages.map((msg) => renderMessage(msg, aliasMap.get(msg.authorAliasId ?? "") ?? null, locale)).join("")}
103
+ </s-stack>`}
104
+ </div>
106
105
 
107
- ${isOpen
106
+ ${isOpen
108
107
  ? `
109
- <s-card style="margin-top:16px;">
110
- <s-box padding="large">
111
- <div id="sch-reply-section">
112
- <s-stack gap="base">
113
- <s-text variant="headingSm">Reply</s-text>
114
- ${renderFormattingField({
108
+ <s-card>
109
+ <s-box padding="large">
110
+ <div id="sch-reply-section">
111
+ <s-stack gap="base">
112
+ <s-heading>Reply</s-heading>
113
+ ${renderFormattingField({
115
114
  id: "sch-reply-body",
116
115
  rows: 4,
117
116
  placeholder: "Write your reply...",
118
117
  extraStyle: "resize:vertical"
119
118
  })}
120
- <div id="sch-reply-files"></div>
121
- <div style="display:flex; justify-content:space-between; align-items:center;">
122
- <s-button id="sch-close-ticket-btn">Close Ticket</s-button>
123
- <s-button variant="primary" id="sch-send-reply-btn">Send Reply</s-button>
124
- </div>
125
- </s-stack>
126
- </div>
127
- </s-box>
128
- </s-card>`
119
+ <div id="sch-reply-files"></div>
120
+ <s-stack direction="inline" justifyContent="space-between" alignItems="center">
121
+ <s-button id="sch-close-ticket-btn">Close Ticket</s-button>
122
+ <s-button variant="primary" id="sch-send-reply-btn">Send Reply</s-button>
123
+ </s-stack>
124
+ </s-stack>
125
+ </div>
126
+ </s-box>
127
+ </s-card>`
129
128
  : ""}
129
+ </s-stack>
130
130
  `);
131
131
  // Back button
132
132
  container.querySelector("#sch-back-btn")?.addEventListener("click", () => {
@@ -198,10 +198,10 @@ export async function renderTicketDetail(container, client, ticketId, emitter) {
198
198
  <s-card>
199
199
  <s-box padding="large">
200
200
  <s-stack gap="base">
201
- <s-banner tone="critical">
202
- <s-text>Failed to load ticket: ${escapeHtml(error instanceof Error ? error.message : String(error))}</s-text>
201
+ <s-banner tone="critical" heading="Failed to load ticket">
202
+ <s-text>${escapeHtml(error instanceof Error ? error.message : String(error))}</s-text>
203
203
  </s-banner>
204
- <s-button variant="plain" id="sch-back-btn">
204
+ <s-button variant="tertiary" id="sch-back-btn">
205
205
  <span style="display:inline-flex; align-items:center; gap:4px;">
206
206
  <svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor"><path d="M12.707 15.707a1 1 0 0 1-1.414 0l-5-5a1 1 0 0 1 0-1.414l5-5a1 1 0 1 1 1.414 1.414L8.414 10l4.293 4.293a1 1 0 0 1 0 1.414z"/></svg>
207
207
  All tickets
@@ -223,46 +223,42 @@ function getInitials(displayName) {
223
223
  }
224
224
  return displayName.slice(0, 2).toUpperCase();
225
225
  }
226
- function renderMessage(msg, alias) {
226
+ function renderMessage(msg, alias, locale) {
227
227
  const isSystem = msg.authorType === "system";
228
228
  const isAgent = msg.authorType === "agent" || msg.authorType === "admin";
229
229
  const isCustomer = !isSystem && !isAgent;
230
230
  if (isSystem) {
231
231
  return `
232
232
  <div style="text-align:center; padding:12px 0;">
233
- <s-text variant="bodySm" tone="subdued">${escapeHtml(msg.bodyPlain)}</s-text>
233
+ <s-text tone="neutral">${escapeHtml(msg.bodyPlain)}</s-text>
234
234
  </div>
235
235
  `;
236
236
  }
237
- const avatarBg = isAgent
238
- ? "var(--p-color-bg-fill-info, #e4f0fe)"
239
- : "var(--p-color-bg-fill-success, #cdfee1)";
240
- const avatarColor = isAgent
241
- ? "var(--p-color-text-info, #2c6ecb)"
242
- : "var(--p-color-text-success, #008060)";
243
- const avatarLetter = isAgent ? (alias ? getInitials(alias.displayName) : "S") : "Y";
237
+ const avatarInitials = isAgent ? (alias ? getInitials(alias.displayName) : "S") : "Y";
238
+ const avatarSrc = isAgent && alias?.avatarUrl ? ` src="${escapeHtml(alias.avatarUrl)}"` : "";
244
239
  const label = isAgent ? (alias ? alias.displayName : "Support") : "You";
245
240
  const bubbleBg = isCustomer
246
241
  ? "var(--p-color-bg-fill-info, #e4f0fe)"
247
242
  : "var(--p-color-bg-surface-secondary, #f6f6f7)";
248
- const align = isCustomer ? "flex-end" : "flex-start";
249
- const flexDir = isCustomer ? "row-reverse" : "row";
250
- return `
251
- <div style="display:flex; align-items:flex-start; gap:10px; flex-direction:${flexDir}; padding:10px 0; max-width:85%${isCustomer ? "; margin-left:auto" : ""}">
252
- <div style="width:32px; height:32px; border-radius:50%; background:${avatarBg}; display:flex; align-items:center; justify-content:center; flex-shrink:0;">
253
- <span style="font-size:13px; font-weight:600; color:${avatarColor};">${avatarLetter}</span>
254
- </div>
255
- <div style="min-width:0; text-align:${isCustomer ? "right" : "left"};">
256
- <div style="display:flex; align-items:baseline; gap:8px; justify-content:${align}; margin-bottom:4px;">
257
- <s-text variant="bodySm" fontWeight="semibold">${label}</s-text>
258
- <s-text variant="bodySm" tone="subdued">${formatTimestamp(msg.createdAt)}</s-text>
259
- </div>
260
- <div style="background:${bubbleBg}; padding:10px 14px; border-radius:12px; display:inline-block; text-align:left; max-width:100%;">
261
- <s-text variant="bodyMd" style="white-space:pre-wrap; word-break:break-word;">${formatBodyText(msg.bodyPlain)}</s-text>
262
- </div>
243
+ const avatar = `<s-avatar size="small" initials="${escapeHtml(avatarInitials)}"${avatarSrc}></s-avatar>`;
244
+ const content = `
245
+ <div style="min-width:0;">
246
+ <s-stack direction="inline" alignItems="baseline" gap="small" justifyContent="${isCustomer ? "end" : "start"}" style="margin-bottom:4px;">
247
+ <s-text type="strong">${label}</s-text>
248
+ <s-text tone="neutral">${formatTimestamp(msg.createdAt, locale)}</s-text>
249
+ </s-stack>
250
+ <div style="background:${bubbleBg}; padding:10px 14px; border-radius:12px; display:inline-block; text-align:left; max-width:100%;">
251
+ <s-text style="white-space:pre-wrap; word-break:break-word;">${formatBodyText(msg.bodyPlain)}</s-text>
263
252
  </div>
264
253
  </div>
265
254
  `;
255
+ return `
256
+ <div style="padding:10px 0; max-width:85%;${isCustomer ? " margin-left:auto;" : ""}">
257
+ <s-stack direction="inline" alignItems="start" gap="small">
258
+ ${isCustomer ? content + avatar : avatar + content}
259
+ </s-stack>
260
+ </div>
261
+ `;
266
262
  }
267
263
  function showError(container, message) {
268
264
  const existing = container.querySelector("#sch-error-banner");
@@ -271,7 +267,7 @@ function showError(container, message) {
271
267
  const banner = document.createElement("div");
272
268
  banner.id = "sch-error-banner";
273
269
  setInnerHtml(banner, `
274
- <s-banner tone="critical" style="margin-bottom:12px;">
270
+ <s-banner tone="critical" heading="Error" style="margin-bottom:12px;">
275
271
  <s-text>${escapeHtml(message)}</s-text>
276
272
  </s-banner>
277
273
  `);
@@ -1,8 +1,8 @@
1
- import type { SupportApiClient } from "@catandbox/schrodinger-shopify-adapter/client";
1
+ import type { SupportApiClient } from "@catandbox/schrodinger-contracts";
2
2
  import type { EventEmitter } from "./events";
3
3
  export interface TicketListEvents {
4
4
  [key: string]: unknown;
5
5
  "ticket:select": string;
6
6
  "ticket:create": void;
7
7
  }
8
- export declare function renderTicketList(container: HTMLElement, client: SupportApiClient, emitter: EventEmitter<TicketListEvents>): Promise<void>;
8
+ export declare function renderTicketList(container: HTMLElement, client: SupportApiClient, emitter: EventEmitter<TicketListEvents>, locale?: string): Promise<void>;
@@ -164,27 +164,28 @@ function initHydrogenLogo(el) {
164
164
  renderer.dispose();
165
165
  };
166
166
  }
167
- function formatDate(timestamp) {
168
- return new Date(timestamp * 1000).toLocaleDateString(undefined, {
167
+ function formatDate(timestamp, locale) {
168
+ return new Date(timestamp * 1000).toLocaleDateString(locale, {
169
169
  month: "short",
170
170
  day: "numeric",
171
171
  year: "numeric"
172
172
  });
173
173
  }
174
- function timeAgo(timestamp) {
174
+ function timeAgo(timestamp, locale) {
175
+ const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
175
176
  const seconds = Math.floor(Date.now() / 1000 - timestamp);
176
177
  if (seconds < 60)
177
- return "just now";
178
+ return rtf.format(-seconds, "second");
178
179
  const minutes = Math.floor(seconds / 60);
179
180
  if (minutes < 60)
180
- return `${minutes}m ago`;
181
+ return rtf.format(-minutes, "minute");
181
182
  const hours = Math.floor(minutes / 60);
182
183
  if (hours < 24)
183
- return `${hours}h ago`;
184
+ return rtf.format(-hours, "hour");
184
185
  const days = Math.floor(hours / 24);
185
186
  if (days < 7)
186
- return `${days}d ago`;
187
- return formatDate(timestamp);
187
+ return rtf.format(-days, "day");
188
+ return formatDate(timestamp, locale);
188
189
  }
189
190
  const STATUS_OPTIONS = [
190
191
  { value: "", label: "All statuses" },
@@ -198,7 +199,7 @@ const ORDER_OPTIONS = [
198
199
  { value: "newest", label: "Latest first" },
199
200
  { value: "oldest", label: "Oldest first" }
200
201
  ];
201
- export async function renderTicketList(container, client, emitter) {
202
+ export async function renderTicketList(container, client, emitter, locale) {
202
203
  // Load tickets, categories and Three.js in parallel
203
204
  const [result, portalConfig] = await Promise.all([
204
205
  client.listTickets({}).catch(() => ({ items: [] })),
@@ -319,7 +320,7 @@ export async function renderTicketList(container, client, emitter) {
319
320
  <s-text variant="bodyMd" fontWeight="semibold">${escapeHtml(ticket.title)}</s-text>
320
321
  </div>
321
322
  <div style="margin-top:2px;">
322
- <s-text variant="bodySm" tone="subdued">#${ticket.id.slice(0, 8)} &middot; Updated ${timeAgo(ticket.updatedAt)}</s-text>
323
+ <s-text variant="bodySm" tone="subdued">#${ticket.id.slice(0, 8)} &middot; Updated ${timeAgo(ticket.updatedAt, locale)}</s-text>
323
324
  </div>
324
325
  </div>
325
326
  </div>