@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.
- package/README.md +83 -0
- package/dist/client/client.d.ts +118 -0
- package/dist/client/client.js +298 -0
- package/dist/client/components/CountdownText.d.ts +5 -0
- package/dist/client/components/CountdownText.js +26 -0
- package/dist/client/components/ErrorBanner.d.ts +4 -0
- package/dist/client/components/ErrorBanner.js +8 -0
- package/dist/client/components/StatusBadge.d.ts +5 -0
- package/dist/client/components/StatusBadge.js +21 -0
- package/dist/client/components/SupportNewTicketForm.d.ts +11 -0
- package/dist/client/components/SupportNewTicketForm.js +76 -0
- package/dist/client/components/SupportTicketDetail.d.ts +9 -0
- package/dist/client/components/SupportTicketDetail.js +139 -0
- package/dist/client/components/SupportTicketList.d.ts +9 -0
- package/dist/client/components/SupportTicketList.js +104 -0
- package/dist/client/editor/RichTextEditor.d.ts +10 -0
- package/dist/client/editor/RichTextEditor.js +11 -0
- package/dist/client/hooks/types.d.ts +14 -0
- package/dist/client/hooks/types.js +1 -0
- package/dist/client/hooks/useCategories.d.ts +3 -0
- package/dist/client/hooks/useCategories.js +37 -0
- package/dist/client/hooks/useCreateTicket.d.ts +16 -0
- package/dist/client/hooks/useCreateTicket.js +30 -0
- package/dist/client/hooks/useRatings.d.ts +12 -0
- package/dist/client/hooks/useRatings.js +51 -0
- package/dist/client/hooks/useReply.d.ts +13 -0
- package/dist/client/hooks/useReply.js +31 -0
- package/dist/client/hooks/useSupportClient.d.ts +10 -0
- package/dist/client/hooks/useSupportClient.js +12 -0
- package/dist/client/hooks/useTicket.d.ts +3 -0
- package/dist/client/hooks/useTicket.js +36 -0
- package/dist/client/hooks/useTickets.d.ts +15 -0
- package/dist/client/hooks/useTickets.js +76 -0
- package/dist/client/hooks/useUploadManager.d.ts +28 -0
- package/dist/client/hooks/useUploadManager.js +210 -0
- package/dist/client/index.d.ts +18 -0
- package/dist/client/index.js +17 -0
- package/dist/client/uploads/UploadManagerView.d.ts +10 -0
- package/dist/client/uploads/UploadManagerView.js +26 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.js +3 -0
- package/dist/server/routes.d.ts +15 -0
- package/dist/server/routes.js +353 -0
- package/dist/server/shopifyAuth.d.ts +28 -0
- package/dist/server/shopifyAuth.js +179 -0
- package/dist/server/signing.d.ts +18 -0
- package/dist/server/signing.js +25 -0
- package/dist/server/types.d.ts +60 -0
- package/dist/server/types.js +1 -0
- package/dist/signer.d.ts +29 -0
- package/dist/signer.js +51 -0
- 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,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,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,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 {};
|