@catandbox/schrodinger-shopify-adapter 0.1.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 (54) hide show
  1. package/README.md +83 -0
  2. package/dist/client/client.d.ts +118 -0
  3. package/dist/client/client.js +298 -0
  4. package/dist/client/components/CountdownText.d.ts +5 -0
  5. package/dist/client/components/CountdownText.js +26 -0
  6. package/dist/client/components/ErrorBanner.d.ts +4 -0
  7. package/dist/client/components/ErrorBanner.js +8 -0
  8. package/dist/client/components/StatusBadge.d.ts +5 -0
  9. package/dist/client/components/StatusBadge.js +21 -0
  10. package/dist/client/components/SupportNewTicketForm.d.ts +11 -0
  11. package/dist/client/components/SupportNewTicketForm.js +76 -0
  12. package/dist/client/components/SupportTicketDetail.d.ts +9 -0
  13. package/dist/client/components/SupportTicketDetail.js +139 -0
  14. package/dist/client/components/SupportTicketList.d.ts +9 -0
  15. package/dist/client/components/SupportTicketList.js +104 -0
  16. package/dist/client/editor/RichTextEditor.d.ts +10 -0
  17. package/dist/client/editor/RichTextEditor.js +11 -0
  18. package/dist/client/hooks/types.d.ts +14 -0
  19. package/dist/client/hooks/types.js +1 -0
  20. package/dist/client/hooks/useCategories.d.ts +3 -0
  21. package/dist/client/hooks/useCategories.js +37 -0
  22. package/dist/client/hooks/useCreateTicket.d.ts +16 -0
  23. package/dist/client/hooks/useCreateTicket.js +30 -0
  24. package/dist/client/hooks/useRatings.d.ts +12 -0
  25. package/dist/client/hooks/useRatings.js +51 -0
  26. package/dist/client/hooks/useReply.d.ts +13 -0
  27. package/dist/client/hooks/useReply.js +31 -0
  28. package/dist/client/hooks/useSupportClient.d.ts +10 -0
  29. package/dist/client/hooks/useSupportClient.js +12 -0
  30. package/dist/client/hooks/useTicket.d.ts +3 -0
  31. package/dist/client/hooks/useTicket.js +36 -0
  32. package/dist/client/hooks/useTickets.d.ts +15 -0
  33. package/dist/client/hooks/useTickets.js +76 -0
  34. package/dist/client/hooks/useUploadManager.d.ts +28 -0
  35. package/dist/client/hooks/useUploadManager.js +210 -0
  36. package/dist/client/index.d.ts +18 -0
  37. package/dist/client/index.js +17 -0
  38. package/dist/client/uploads/UploadManagerView.d.ts +10 -0
  39. package/dist/client/uploads/UploadManagerView.js +26 -0
  40. package/dist/index.d.ts +3 -0
  41. package/dist/index.js +3 -0
  42. package/dist/server/index.d.ts +4 -0
  43. package/dist/server/index.js +3 -0
  44. package/dist/server/routes.d.ts +15 -0
  45. package/dist/server/routes.js +353 -0
  46. package/dist/server/shopifyAuth.d.ts +28 -0
  47. package/dist/server/shopifyAuth.js +179 -0
  48. package/dist/server/signing.d.ts +18 -0
  49. package/dist/server/signing.js +25 -0
  50. package/dist/server/types.d.ts +60 -0
  51. package/dist/server/types.js +1 -0
  52. package/dist/signer.d.ts +29 -0
  53. package/dist/signer.js +51 -0
  54. package/package.json +51 -0
package/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # `@catandbox/schrodinger-shopify-adapter`
2
+
3
+ Shopify adapter package for Schrodinger support portal integrations.
4
+
5
+ ## Exports
6
+
7
+ - `@catandbox/schrodinger-shopify-adapter/server`
8
+ - `@catandbox/schrodinger-shopify-adapter/client`
9
+ - `@catandbox/schrodinger-shopify-adapter/signer`
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install @catandbox/schrodinger-shopify-adapter @catandbox/schrodinger-contracts
15
+ ```
16
+
17
+ Peer dependencies:
18
+
19
+ - `react`
20
+ - `react-dom`
21
+ - `@shopify/polaris`
22
+ - `@shopify/polaris-icons`
23
+
24
+ ## Minimal Server Integration
25
+
26
+ ```ts
27
+ import { createShopifyProxyHandler } from "@catandbox/schrodinger-shopify-adapter/server";
28
+
29
+ const proxy = createShopifyProxyHandler({
30
+ schApiBaseUrl: process.env.SCH_API_BASE_URL!,
31
+ schAppId: process.env.SCH_APP_ID!,
32
+ schKeyId: process.env.SCH_KEY_ID!,
33
+ schSecret: process.env.SCH_SECRET!,
34
+ shopifyApiKey: process.env.SHOPIFY_API_KEY!,
35
+ shopifyApiSecret: process.env.SHOPIFY_API_SECRET!
36
+ });
37
+
38
+ export async function handleRequest(request: Request): Promise<Response> {
39
+ return await proxy(request);
40
+ }
41
+ ```
42
+
43
+ ## Minimal Client Integration
44
+
45
+ ```tsx
46
+ import "@shopify/polaris/build/esm/styles.css";
47
+ import { AppProvider } from "@shopify/polaris";
48
+ import {
49
+ SupportClientProvider,
50
+ SupportTicketList
51
+ } from "@catandbox/schrodinger-shopify-adapter/client";
52
+
53
+ export function SupportScreen() {
54
+ return (
55
+ <AppProvider i18n={{}}>
56
+ <SupportClientProvider>
57
+ <SupportTicketList />
58
+ </SupportClientProvider>
59
+ </AppProvider>
60
+ );
61
+ }
62
+ ```
63
+
64
+ ## Playwright Scaffold
65
+
66
+ The package includes Playwright scaffold coverage for portal flows:
67
+
68
+ - ticket creation with prefill + attachments + notify override
69
+ - list visibility and bulk archive
70
+ - detail thread/reply
71
+ - close/reopen transitions
72
+ - message and ticket ratings
73
+
74
+ Run locally against an integrated host app:
75
+
76
+ ```bash
77
+ SUPPORT_E2E_ENABLED=1 \
78
+ SUPPORT_PORTAL_BASE_URL=http://127.0.0.1:4173 \
79
+ SUPPORT_E2E_NEW_TICKET_URL=http://127.0.0.1:4173/support/new \
80
+ SUPPORT_E2E_TICKETS_URL=http://127.0.0.1:4173/support/tickets \
81
+ SUPPORT_E2E_DETAIL_URL_TEMPLATE=http://127.0.0.1:4173/support/tickets/{ticketId} \
82
+ npm run -w @catandbox/schrodinger-shopify-adapter test:e2e
83
+ ```
@@ -0,0 +1,118 @@
1
+ import { type Message, type RatingRequest, type Ticket, type TicketStatus } from "@catandbox/schrodinger-contracts";
2
+ export interface ClientHeadersProvider {
3
+ (): Record<string, string> | Promise<Record<string, string>>;
4
+ }
5
+ export interface SupportClientOptions {
6
+ basePath?: string;
7
+ baseUrl?: string;
8
+ fetchImpl?: typeof fetch;
9
+ headers?: Record<string, string>;
10
+ getHeaders?: ClientHeadersProvider;
11
+ }
12
+ export interface ListTicketsParams {
13
+ status?: TicketStatus;
14
+ }
15
+ export interface SupportCategory {
16
+ id: string;
17
+ integrationId?: string;
18
+ name: string;
19
+ isDeleted?: boolean;
20
+ }
21
+ export interface TicketDetailData {
22
+ ticket: Ticket;
23
+ messages: Message[];
24
+ events: Array<{
25
+ id: string;
26
+ eventType: string;
27
+ createdAt: number;
28
+ payloadJson?: string;
29
+ }>;
30
+ }
31
+ export interface UploadInputFile {
32
+ filename: string;
33
+ mime: string;
34
+ sizeBytes: number;
35
+ sha256: string;
36
+ }
37
+ export interface UploadInitResult {
38
+ uploads: Array<{
39
+ uploadId: string;
40
+ attachmentId: string;
41
+ filename: string;
42
+ putUrl: string;
43
+ }>;
44
+ }
45
+ export interface UploadCompleteInput {
46
+ ticketId: string;
47
+ uploads: Array<{
48
+ uploadId: string;
49
+ sizeBytes: number;
50
+ sha256: string;
51
+ }>;
52
+ messageId?: string | null;
53
+ }
54
+ export declare class SupportApiError extends Error {
55
+ readonly code: string;
56
+ readonly requestId: string | null;
57
+ readonly status: number;
58
+ constructor(input: {
59
+ message: string;
60
+ status: number;
61
+ code?: string;
62
+ requestId?: string | null;
63
+ });
64
+ }
65
+ export declare class SupportApiClient {
66
+ private readonly basePath;
67
+ private readonly baseUrl;
68
+ private readonly fetchImpl;
69
+ private readonly staticHeaders;
70
+ private readonly getHeaders;
71
+ private readonly contractsClient;
72
+ constructor(options?: SupportClientOptions);
73
+ getCategories(): Promise<{
74
+ items: SupportCategory[];
75
+ }>;
76
+ getPortalConfig(): Promise<{
77
+ categories: Array<{
78
+ id: string;
79
+ name: string;
80
+ }>;
81
+ aliases: unknown[];
82
+ }>;
83
+ listTickets(params?: ListTicketsParams): Promise<{
84
+ items: Ticket[];
85
+ }>;
86
+ getTicket(ticketId: string): Promise<TicketDetailData>;
87
+ createTicket(input: {
88
+ categoryId?: string | null;
89
+ title: string;
90
+ body: string;
91
+ clientMessageId?: string | null;
92
+ }): Promise<Ticket>;
93
+ createReply(ticketId: string, input: {
94
+ body: string;
95
+ clientMessageId?: string | null;
96
+ }): Promise<Message>;
97
+ closeTicket(ticketId: string): Promise<Ticket>;
98
+ archiveTicket(ticketId: string): Promise<Ticket>;
99
+ reopenTicket(ticketId: string): Promise<Ticket>;
100
+ rateTicket(ticketId: string, input: Omit<RatingRequest, "tenantExternalId" | "principalExternalId">): Promise<{
101
+ ok: true;
102
+ }>;
103
+ rateMessage(messageId: string, input: Omit<RatingRequest, "tenantExternalId" | "principalExternalId">): Promise<{
104
+ ok: true;
105
+ aliasRated: boolean;
106
+ }>;
107
+ initUploads(input: {
108
+ ticketId: string;
109
+ files: UploadInputFile[];
110
+ }): Promise<UploadInitResult>;
111
+ completeUploads(input: UploadCompleteInput): Promise<{
112
+ completed: number;
113
+ scanStatus: "pending";
114
+ }>;
115
+ putUpload(putUrl: string, file: File, onProgress?: (loaded: number, total: number) => void): Promise<void>;
116
+ private requestJson;
117
+ private resolveHeaders;
118
+ }
@@ -0,0 +1,298 @@
1
+ import { SchrodingerApiClient } from "@catandbox/schrodinger-contracts";
2
+ export class SupportApiError extends Error {
3
+ code;
4
+ requestId;
5
+ status;
6
+ constructor(input) {
7
+ super(input.message);
8
+ this.name = "SupportApiError";
9
+ this.status = input.status;
10
+ this.code = input.code ?? "SUPPORT_API_ERROR";
11
+ this.requestId = input.requestId ?? null;
12
+ }
13
+ }
14
+ export class SupportApiClient {
15
+ basePath;
16
+ baseUrl;
17
+ fetchImpl;
18
+ staticHeaders;
19
+ getHeaders;
20
+ contractsClient;
21
+ constructor(options = {}) {
22
+ this.basePath = normalizeBasePath(options.basePath ?? "/support/api");
23
+ this.baseUrl = resolveBaseUrl(options.baseUrl, this.basePath);
24
+ this.fetchImpl = options.fetchImpl ?? fetch;
25
+ this.staticHeaders = options.headers ?? {};
26
+ this.getHeaders = options.getHeaders ?? null;
27
+ this.contractsClient = new SchrodingerApiClient({
28
+ baseUrl: this.baseUrl,
29
+ fetchImpl: (input, init) => {
30
+ const requestUrl = new URL(typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url, this.baseUrl);
31
+ requestUrl.pathname = requestUrl.pathname.replace(new RegExp(`^${escapeRegExp(this.basePath)}/v1(?=/|$)`), // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp
32
+ this.basePath);
33
+ return this.fetchImpl(requestUrl.toString(), init);
34
+ }
35
+ });
36
+ }
37
+ async getCategories() {
38
+ return await this.requestJson("GET", "/categories");
39
+ }
40
+ async getPortalConfig() {
41
+ return await this.contractsClient.request("GET /v1/portal-config", undefined, {
42
+ headers: await this.resolveHeaders()
43
+ });
44
+ }
45
+ async listTickets(params = {}) {
46
+ return await this.contractsClient.request("GET /v1/tickets", undefined, {
47
+ headers: await this.resolveHeaders(),
48
+ query: {
49
+ status: params.status
50
+ }
51
+ });
52
+ }
53
+ async getTicket(ticketId) {
54
+ const payload = await this.requestJson("GET", `/tickets/${encodeURIComponent(ticketId)}`);
55
+ return normalizeTicketDetailPayload(payload);
56
+ }
57
+ async createTicket(input) {
58
+ return await this.contractsClient.request("POST /v1/tickets", input, {
59
+ headers: await this.resolveHeaders()
60
+ });
61
+ }
62
+ async createReply(ticketId, input) {
63
+ return await this.contractsClient.request("POST /v1/tickets/{ticketId}/messages", input, {
64
+ headers: await this.resolveHeaders(),
65
+ pathParams: {
66
+ ticketId
67
+ }
68
+ });
69
+ }
70
+ async closeTicket(ticketId) {
71
+ return await this.requestJson("POST", `/tickets/${encodeURIComponent(ticketId)}/close`, {});
72
+ }
73
+ async archiveTicket(ticketId) {
74
+ return await this.requestJson("POST", `/tickets/${encodeURIComponent(ticketId)}/archive`, {});
75
+ }
76
+ async reopenTicket(ticketId) {
77
+ return await this.requestJson("POST", `/tickets/${encodeURIComponent(ticketId)}/reopen`, {});
78
+ }
79
+ async rateTicket(ticketId, input) {
80
+ return await this.contractsClient.request("POST /v1/tickets/{ticketId}/ratings", input, {
81
+ headers: await this.resolveHeaders(),
82
+ pathParams: {
83
+ ticketId
84
+ }
85
+ });
86
+ }
87
+ async rateMessage(messageId, input) {
88
+ return await this.contractsClient.request("POST /v1/messages/{messageId}/ratings", input, {
89
+ headers: await this.resolveHeaders(),
90
+ pathParams: {
91
+ messageId
92
+ }
93
+ });
94
+ }
95
+ async initUploads(input) {
96
+ return await this.contractsClient.request("POST /v1/uploads/init", input, {
97
+ headers: await this.resolveHeaders()
98
+ });
99
+ }
100
+ async completeUploads(input) {
101
+ return await this.contractsClient.request("POST /v1/uploads/complete", input, {
102
+ headers: await this.resolveHeaders()
103
+ });
104
+ }
105
+ async putUpload(putUrl, file, onProgress) {
106
+ if (onProgress) {
107
+ await uploadViaXmlHttpRequest({
108
+ url: putUrl,
109
+ file,
110
+ onProgress
111
+ });
112
+ return;
113
+ }
114
+ await uploadViaXmlHttpRequest({
115
+ url: putUrl,
116
+ file
117
+ });
118
+ }
119
+ async requestJson(method, path, body) {
120
+ const url = new URL(`${this.baseUrl}${path}`);
121
+ const headers = await this.resolveHeaders();
122
+ const response = await this.fetchImpl(url.toString(), {
123
+ method,
124
+ headers: {
125
+ ...headers,
126
+ ...(body === undefined ? {} : { "content-type": "application/json" })
127
+ },
128
+ body: body === undefined ? null : JSON.stringify(body)
129
+ });
130
+ if (!response.ok) {
131
+ throw await toSupportApiError(response);
132
+ }
133
+ return (await response.json());
134
+ }
135
+ async resolveHeaders() {
136
+ const dynamic = this.getHeaders ? await this.getHeaders() : {};
137
+ return {
138
+ ...this.staticHeaders,
139
+ ...dynamic
140
+ };
141
+ }
142
+ }
143
+ async function uploadViaXmlHttpRequest(input) {
144
+ await new Promise((resolve, reject) => {
145
+ const xhr = new XMLHttpRequest();
146
+ xhr.open("PUT", input.url);
147
+ xhr.setRequestHeader("content-type", input.file.type || "application/octet-stream");
148
+ xhr.upload.onprogress = (event) => {
149
+ if (event.lengthComputable) {
150
+ input.onProgress?.(event.loaded, event.total);
151
+ }
152
+ };
153
+ xhr.onerror = () => reject(new Error("Upload failed"));
154
+ xhr.onload = () => {
155
+ if (xhr.status >= 200 && xhr.status < 300) {
156
+ resolve();
157
+ }
158
+ else {
159
+ reject(new Error(`Upload failed with status ${xhr.status}`));
160
+ }
161
+ };
162
+ xhr.send(input.file);
163
+ });
164
+ }
165
+ async function toSupportApiError(response) {
166
+ try {
167
+ const payload = (await response.json());
168
+ return new SupportApiError(payload.error
169
+ ? {
170
+ status: response.status,
171
+ message: payload.message ?? `Request failed with status ${response.status}`,
172
+ code: payload.error,
173
+ requestId: payload.requestId ?? response.headers.get("X-Request-Id")
174
+ }
175
+ : {
176
+ status: response.status,
177
+ message: payload.message ?? `Request failed with status ${response.status}`,
178
+ requestId: payload.requestId ?? response.headers.get("X-Request-Id")
179
+ });
180
+ }
181
+ catch {
182
+ return new SupportApiError({
183
+ status: response.status,
184
+ message: `Request failed with status ${response.status}`,
185
+ requestId: response.headers.get("X-Request-Id")
186
+ });
187
+ }
188
+ }
189
+ function resolveBaseUrl(baseUrl, basePath) {
190
+ if (baseUrl) {
191
+ return trimTrailingSlash(baseUrl);
192
+ }
193
+ if (typeof window !== "undefined" && window.location?.origin) {
194
+ return `${window.location.origin}${basePath}`;
195
+ }
196
+ return `http://localhost${basePath}`;
197
+ }
198
+ function normalizeBasePath(path) {
199
+ const withSlash = path.startsWith("/") ? path : `/${path}`;
200
+ return withSlash.replace(/\/+$/, "");
201
+ }
202
+ function trimTrailingSlash(value) {
203
+ return value.replace(/\/+$/, "");
204
+ }
205
+ function escapeRegExp(value) {
206
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
207
+ }
208
+ function normalizeTicketDetailPayload(payload) {
209
+ if (isTicket(payload)) {
210
+ return {
211
+ ticket: payload,
212
+ messages: [],
213
+ events: []
214
+ };
215
+ }
216
+ if (isRecord(payload) && isTicket(payload.ticket)) {
217
+ const messages = asMessageArray(payload.messages);
218
+ const events = asEventArray(payload.events);
219
+ return {
220
+ ticket: payload.ticket,
221
+ messages,
222
+ events
223
+ };
224
+ }
225
+ throw new Error("Invalid ticket detail payload");
226
+ }
227
+ function asMessageArray(value) {
228
+ if (!Array.isArray(value)) {
229
+ return [];
230
+ }
231
+ return value.filter(isMessage).sort((left, right) => left.createdAt - right.createdAt);
232
+ }
233
+ function asEventArray(value) {
234
+ if (!Array.isArray(value)) {
235
+ return [];
236
+ }
237
+ const events = [];
238
+ for (const entry of value) {
239
+ if (!isRecord(entry)) {
240
+ continue;
241
+ }
242
+ if (typeof entry.id !== "string" ||
243
+ typeof entry.eventType !== "string" ||
244
+ typeof entry.createdAt !== "number") {
245
+ continue;
246
+ }
247
+ if ("payloadJson" in entry &&
248
+ entry.payloadJson !== undefined &&
249
+ typeof entry.payloadJson !== "string") {
250
+ continue;
251
+ }
252
+ if (typeof entry.payloadJson === "string") {
253
+ events.push({
254
+ id: entry.id,
255
+ eventType: entry.eventType,
256
+ createdAt: entry.createdAt,
257
+ payloadJson: entry.payloadJson
258
+ });
259
+ }
260
+ else {
261
+ events.push({
262
+ id: entry.id,
263
+ eventType: entry.eventType,
264
+ createdAt: entry.createdAt
265
+ });
266
+ }
267
+ }
268
+ return events.sort((left, right) => left.createdAt - right.createdAt);
269
+ }
270
+ function isTicket(value) {
271
+ if (!isRecord(value)) {
272
+ return false;
273
+ }
274
+ return (typeof value.id === "string" &&
275
+ typeof value.tenantId === "string" &&
276
+ typeof value.principalId === "string" &&
277
+ (typeof value.categoryId === "string" || value.categoryId === null) &&
278
+ typeof value.status === "string" &&
279
+ typeof value.title === "string" &&
280
+ (typeof value.assignedAliasId === "string" || value.assignedAliasId === null) &&
281
+ typeof value.createdAt === "number" &&
282
+ typeof value.updatedAt === "number");
283
+ }
284
+ function isMessage(value) {
285
+ if (!isRecord(value)) {
286
+ return false;
287
+ }
288
+ return (typeof value.id === "string" &&
289
+ typeof value.ticketId === "string" &&
290
+ typeof value.authorType === "string" &&
291
+ typeof value.isPublic === "boolean" &&
292
+ typeof value.bodyPlain === "string" &&
293
+ (typeof value.authorAliasId === "string" || value.authorAliasId === null) &&
294
+ typeof value.createdAt === "number");
295
+ }
296
+ function isRecord(value) {
297
+ return typeof value === "object" && value !== null;
298
+ }
@@ -0,0 +1,5 @@
1
+ import React from "react";
2
+ export declare function CountdownText(props: {
3
+ dueAt: number | null;
4
+ label: string;
5
+ }): React.ReactElement | null;
@@ -0,0 +1,26 @@
1
+ import { jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text } from "@shopify/polaris";
3
+ import { useEffect, useMemo, useState } from "react";
4
+ export function CountdownText(props) {
5
+ const [now, setNow] = useState(() => Math.floor(Date.now() / 1000));
6
+ useEffect(() => {
7
+ const timer = window.setInterval(() => {
8
+ setNow(Math.floor(Date.now() / 1000));
9
+ }, 30_000);
10
+ return () => window.clearInterval(timer);
11
+ }, []);
12
+ const value = useMemo(() => {
13
+ if (!props.dueAt) {
14
+ return null;
15
+ }
16
+ const secondsLeft = Math.max(0, props.dueAt - now);
17
+ const days = Math.floor(secondsLeft / 86_400);
18
+ const hours = Math.floor((secondsLeft % 86_400) / 3_600);
19
+ const minutes = Math.floor((secondsLeft % 3_600) / 60);
20
+ return `${days}d ${hours}h ${minutes}m`;
21
+ }, [now, props.dueAt]);
22
+ if (!value) {
23
+ return null;
24
+ }
25
+ return (_jsxs(Text, { as: "p", tone: "subdued", variant: "bodySm", children: [props.label, ": ", value] }));
26
+ }
@@ -0,0 +1,4 @@
1
+ import React from "react";
2
+ export declare function ErrorBanner(props: {
3
+ error: Error | null;
4
+ }): React.ReactElement | null;
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Banner, BlockStack, Text } from "@shopify/polaris";
3
+ export function ErrorBanner(props) {
4
+ if (!props.error) {
5
+ return null;
6
+ }
7
+ return (_jsx(Banner, { title: "Something went wrong", tone: "critical", children: _jsx(BlockStack, { gap: "200", children: _jsx(Text, { as: "p", variant: "bodyMd", children: props.error.message }) }) }));
8
+ }
@@ -0,0 +1,5 @@
1
+ import type { TicketStatus } from "@catandbox/schrodinger-contracts";
2
+ import React from "react";
3
+ export declare function StatusBadge(props: {
4
+ status: TicketStatus;
5
+ }): React.ReactElement;
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Badge } from "@shopify/polaris";
3
+ export function StatusBadge(props) {
4
+ return _jsx(Badge, { tone: toBadgeTone(props.status), children: props.status });
5
+ }
6
+ function toBadgeTone(status) {
7
+ switch (status) {
8
+ case "Active":
9
+ return "success";
10
+ case "InProgress":
11
+ return "info";
12
+ case "AwaitingResponse":
13
+ return "attention";
14
+ case "Closed":
15
+ return "warning";
16
+ case "Archived":
17
+ return "critical";
18
+ default:
19
+ return "info";
20
+ }
21
+ }
@@ -0,0 +1,11 @@
1
+ import React from "react";
2
+ import type { HookClientOptions } from "../hooks/types";
3
+ interface SupportNewTicketFormProps extends HookClientOptions {
4
+ prefill?: {
5
+ title?: string;
6
+ categoryId?: string | null;
7
+ description?: string;
8
+ };
9
+ }
10
+ export declare function SupportNewTicketForm(props: SupportNewTicketFormProps): React.ReactElement;
11
+ export {};
@@ -0,0 +1,76 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Banner, BlockStack, Box, Button, Card, Checkbox, Frame, InlineGrid, InlineStack, Page, Select, Spinner, Text, TextField, Toast } from "@shopify/polaris";
3
+ import { useCallback, useMemo, useState } from "react";
4
+ import { RichTextEditor } from "../editor/RichTextEditor";
5
+ import { useCategories } from "../hooks/useCategories";
6
+ import { useCreateTicket } from "../hooks/useCreateTicket";
7
+ import { useSupportClient } from "../hooks/useSupportClient";
8
+ import { useUploadManager } from "../hooks/useUploadManager";
9
+ import { UploadManagerView } from "../uploads/UploadManagerView";
10
+ import { ErrorBanner } from "./ErrorBanner";
11
+ export function SupportNewTicketForm(props) {
12
+ const client = useSupportClient(props.client, props.clientOptions);
13
+ const categories = useCategories({ client });
14
+ const createTicket = useCreateTicket({ client });
15
+ const uploads = useUploadManager({ client, maxParallel: 3 });
16
+ const [title, setTitle] = useState(props.prefill?.title ?? "");
17
+ const [categoryId, setCategoryId] = useState(props.prefill?.categoryId ?? "");
18
+ const [body, setBody] = useState(props.prefill?.description ?? "");
19
+ const [notify, setNotify] = useState(true);
20
+ const [notifyEmail, setNotifyEmail] = useState("");
21
+ const [errors, setErrors] = useState({});
22
+ const [toastContent, setToastContent] = useState(null);
23
+ const [acknowledgement, setAcknowledgement] = useState(null);
24
+ const categoryOptions = useMemo(() => {
25
+ const items = categories.data ?? [];
26
+ return [
27
+ { label: "Choose a category", value: "" },
28
+ ...items.map((item) => ({ label: item.name, value: item.id }))
29
+ ];
30
+ }, [categories.data]);
31
+ const submit = useCallback(async () => {
32
+ const nextErrors = {};
33
+ if (!categoryId) {
34
+ nextErrors.categoryId = "Category is required";
35
+ }
36
+ if (!title.trim()) {
37
+ nextErrors.title = "Title is required";
38
+ }
39
+ if (!body.trim()) {
40
+ nextErrors.body = "Description is required";
41
+ }
42
+ if (!uploads.canSubmit) {
43
+ nextErrors.uploads = "Finish uploads or remove failed files before submitting";
44
+ }
45
+ if (notifyEmail.trim() && !notifyEmail.includes("@")) {
46
+ nextErrors.email = "Email override must be a valid email address";
47
+ }
48
+ setErrors(nextErrors);
49
+ if (Object.keys(nextErrors).length > 0) {
50
+ return;
51
+ }
52
+ const ticket = await createTicket.createTicket({
53
+ categoryId: categoryId || null,
54
+ title: title.trim(),
55
+ body: body.trim()
56
+ });
57
+ if (uploads.items.length > 0) {
58
+ await uploads.uploadForTicket(ticket.id);
59
+ }
60
+ const etaLabel = notify
61
+ ? "Acknowledged. First response ETA is pending server estimate."
62
+ : "Acknowledged. Notifications are disabled for this request.";
63
+ setAcknowledgement({
64
+ ticketId: ticket.id,
65
+ etaLabel
66
+ });
67
+ setToastContent("Support ticket submitted");
68
+ setTitle("");
69
+ setCategoryId("");
70
+ setBody("");
71
+ setNotify(true);
72
+ setNotifyEmail("");
73
+ uploads.clear();
74
+ }, [body, categoryId, createTicket, notify, notifyEmail, title, uploads]);
75
+ return (_jsx(Frame, { children: _jsxs(Page, { title: "New support ticket", subtitle: "Create a ticket for customer support", children: [_jsxs(BlockStack, { gap: "400", children: [_jsx(ErrorBanner, { error: categories.error }), _jsx(ErrorBanner, { error: createTicket.error }), _jsx(ErrorBanner, { error: uploads.error }), categories.loading ? (_jsx(Card, { children: _jsx(InlineStack, { align: "center", children: _jsx(Spinner, { size: "large" }) }) })) : (_jsx(Card, { children: _jsxs(BlockStack, { gap: "300", children: [_jsxs(InlineGrid, { columns: { xs: "1fr", md: "1fr 1fr" }, gap: "300", children: [_jsx(Select, { label: "Category", options: categoryOptions, value: categoryId, onChange: setCategoryId, error: errors.categoryId }), _jsx(TextField, { label: "Title", value: title, onChange: setTitle, autoComplete: "off", error: errors.title })] }), _jsx(RichTextEditor, { label: "Description", value: body, onChange: setBody, error: errors.body, helpText: "Use formatting shortcuts for bold, italic, underline and links." }), _jsx(Card, { children: _jsxs(BlockStack, { gap: "200", children: [_jsx(Text, { as: "h3", variant: "headingSm", children: "Attachments" }), _jsx(UploadManagerView, { items: uploads.items, onAddFiles: (files) => uploads.addFiles(files), onRemoveFile: uploads.removeFile }), errors.uploads ? (_jsx(Text, { as: "p", tone: "critical", variant: "bodySm", children: errors.uploads })) : null] }) }), _jsx(Box, { children: _jsxs(BlockStack, { gap: "200", children: [_jsx(Checkbox, { label: "Notify me about ticket updates", checked: notify, onChange: (checked) => setNotify(Boolean(checked)) }), notify ? (_jsx(TextField, { label: "Notification email override (optional)", value: notifyEmail, onChange: setNotifyEmail, autoComplete: "off", error: errors.email, helpText: "Leave blank to use the authenticated Shopify user email." })) : null] }) }), _jsx(InlineStack, { align: "end", children: _jsx(Button, { onClick: () => void submit(), variant: "primary", loading: createTicket.loading || uploads.loading, children: "Submit ticket" }) })] }) })), acknowledgement ? (_jsx(Banner, { title: "Ticket submitted", tone: "success", children: _jsxs(BlockStack, { gap: "100", children: [_jsxs(Text, { as: "p", variant: "bodyMd", children: ["Ticket #", acknowledgement.ticketId, " was created successfully."] }), _jsx(Text, { as: "p", variant: "bodySm", tone: "subdued", children: acknowledgement.etaLabel })] }) })) : null] }), toastContent ? (_jsx(Toast, { content: toastContent, onDismiss: () => setToastContent(null) })) : null] }) }));
76
+ }
@@ -0,0 +1,9 @@
1
+ import type { Ticket } from "@catandbox/schrodinger-contracts";
2
+ import React from "react";
3
+ import type { HookClientOptions } from "../hooks/types";
4
+ interface SupportTicketDetailProps extends HookClientOptions {
5
+ ticketId: string;
6
+ onTicketUpdated?: (ticket: Ticket) => void;
7
+ }
8
+ export declare function SupportTicketDetail(props: SupportTicketDetailProps): React.ReactElement;
9
+ export {};