@catandbox/schrodinger-shopify-adapter 0.1.13 → 0.1.14
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/dist/client/client.d.ts +2 -2
- package/dist/client/client.js +38 -2
- package/dist/client/components/SupportTicketDetail.d.ts +1 -0
- package/dist/client/components/SupportTicketDetail.js +31 -5
- package/dist/client/components/SupportTicketList.d.ts +1 -0
- package/dist/client/components/SupportTicketList.js +41 -15
- package/dist/client/editor/RichTextEditor.js +30 -6
- package/dist/client/hooks/usePortalConfig.d.ts +11 -0
- package/dist/client/hooks/usePortalConfig.js +36 -0
- package/package.json +1 -1
package/dist/client/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Message, type RatingRequest, type Ticket, type TicketStatus } from "@catandbox/schrodinger-contracts";
|
|
1
|
+
import { type Alias, type Message, type RatingRequest, type Ticket, type TicketStatus } from "@catandbox/schrodinger-contracts";
|
|
2
2
|
export interface ClientHeadersProvider {
|
|
3
3
|
(): Record<string, string> | Promise<Record<string, string>>;
|
|
4
4
|
}
|
|
@@ -78,7 +78,7 @@ export declare class SupportApiClient {
|
|
|
78
78
|
id: string;
|
|
79
79
|
name: string;
|
|
80
80
|
}>;
|
|
81
|
-
aliases:
|
|
81
|
+
aliases: Alias[];
|
|
82
82
|
}>;
|
|
83
83
|
listTickets(params?: ListTicketsParams): Promise<{
|
|
84
84
|
items: Ticket[];
|
package/dist/client/client.js
CHANGED
|
@@ -51,8 +51,18 @@ export class SupportApiClient {
|
|
|
51
51
|
});
|
|
52
52
|
}
|
|
53
53
|
async getTicket(ticketId) {
|
|
54
|
-
const payload = await
|
|
55
|
-
|
|
54
|
+
const [payload, historyPayload] = await Promise.all([
|
|
55
|
+
this.requestJson("GET", `/tickets/${encodeURIComponent(ticketId)}`),
|
|
56
|
+
this.requestJson("GET", `/tickets/${encodeURIComponent(ticketId)}/history`).catch(() => null)
|
|
57
|
+
]);
|
|
58
|
+
const detail = normalizeTicketDetailPayload(payload);
|
|
59
|
+
if (historyPayload !== null) {
|
|
60
|
+
const historyEvents = asEventArrayFromHistory(historyPayload);
|
|
61
|
+
if (historyEvents.length > 0) {
|
|
62
|
+
detail.events = historyEvents;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return detail;
|
|
56
66
|
}
|
|
57
67
|
async createTicket(input) {
|
|
58
68
|
return await this.contractsClient.request("POST /v1/tickets", input, {
|
|
@@ -296,3 +306,29 @@ function isMessage(value) {
|
|
|
296
306
|
function isRecord(value) {
|
|
297
307
|
return typeof value === "object" && value !== null;
|
|
298
308
|
}
|
|
309
|
+
function asEventArrayFromHistory(payload) {
|
|
310
|
+
const items = isRecord(payload) && Array.isArray(payload.items) ? payload.items : [];
|
|
311
|
+
const events = [];
|
|
312
|
+
for (const entry of items) {
|
|
313
|
+
if (!isRecord(entry)) {
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (typeof entry.id !== "string" ||
|
|
317
|
+
typeof entry.eventType !== "string" ||
|
|
318
|
+
typeof entry.createdAt !== "number") {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
const payloadJson = typeof entry.payloadJson === "string"
|
|
322
|
+
? entry.payloadJson
|
|
323
|
+
: isRecord(entry.payload)
|
|
324
|
+
? JSON.stringify(entry.payload)
|
|
325
|
+
: undefined;
|
|
326
|
+
events.push({
|
|
327
|
+
id: entry.id,
|
|
328
|
+
eventType: entry.eventType,
|
|
329
|
+
createdAt: entry.createdAt,
|
|
330
|
+
...(payloadJson !== undefined ? { payloadJson } : {})
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
return events.sort((left, right) => left.createdAt - right.createdAt);
|
|
334
|
+
}
|
|
@@ -4,6 +4,7 @@ import type { HookClientOptions } from "../hooks/types";
|
|
|
4
4
|
interface SupportTicketDetailProps extends HookClientOptions {
|
|
5
5
|
ticketId: string;
|
|
6
6
|
onTicketUpdated?: (ticket: Ticket) => void;
|
|
7
|
+
locale?: string;
|
|
7
8
|
}
|
|
8
9
|
export declare function SupportTicketDetail(props: SupportTicketDetailProps): React.ReactElement;
|
|
9
10
|
export {};
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Banner, BlockStack, Box, Button, Card, Frame, InlineGrid, InlineStack, Page, Select, Text, TextField, Toast } from "@shopify/polaris";
|
|
2
|
+
import { Avatar, Banner, BlockStack, Box, Button, Card, Frame, InlineGrid, InlineStack, Page, Select, Text, TextField, Toast } from "@shopify/polaris";
|
|
3
3
|
import { useCallback, useMemo, useState } from "react";
|
|
4
4
|
import { RichTextEditor } from "../editor/RichTextEditor";
|
|
5
5
|
import { useRatings } from "../hooks/useRatings";
|
|
6
6
|
import { useReply } from "../hooks/useReply";
|
|
7
7
|
import { useSupportClient } from "../hooks/useSupportClient";
|
|
8
|
+
import { usePortalConfig } from "../hooks/usePortalConfig";
|
|
8
9
|
import { useTicket } from "../hooks/useTicket";
|
|
9
10
|
import { CountdownText } from "./CountdownText";
|
|
10
11
|
import { ErrorBanner } from "./ErrorBanner";
|
|
@@ -21,6 +22,7 @@ export function SupportTicketDetail(props) {
|
|
|
21
22
|
const ticketQuery = useTicket(props.ticketId, { client });
|
|
22
23
|
const reply = useReply(props.ticketId, { client });
|
|
23
24
|
const ratings = useRatings({ client });
|
|
25
|
+
const portalConfig = usePortalConfig({ client });
|
|
24
26
|
const [replyBody, setReplyBody] = useState("");
|
|
25
27
|
const [toastContent, setToastContent] = useState(null);
|
|
26
28
|
const [statusError, setStatusError] = useState(null);
|
|
@@ -31,6 +33,16 @@ export function SupportTicketDetail(props) {
|
|
|
31
33
|
const ticket = ticketQuery.data?.ticket ?? null;
|
|
32
34
|
const messages = ticketQuery.data?.messages ?? [];
|
|
33
35
|
const events = ticketQuery.data?.events ?? [];
|
|
36
|
+
const aliasMap = useMemo(() => {
|
|
37
|
+
const map = new Map();
|
|
38
|
+
for (const alias of portalConfig.data?.aliases ?? []) {
|
|
39
|
+
map.set(alias.id, alias);
|
|
40
|
+
}
|
|
41
|
+
return map;
|
|
42
|
+
}, [portalConfig.data]);
|
|
43
|
+
const assignedAlias = ticket?.assignedAliasId
|
|
44
|
+
? (aliasMap.get(ticket.assignedAliasId) ?? null)
|
|
45
|
+
: null;
|
|
34
46
|
const updateTicketStatus = useCallback(async (action) => {
|
|
35
47
|
if (!ticket) {
|
|
36
48
|
return;
|
|
@@ -104,18 +116,32 @@ export function SupportTicketDetail(props) {
|
|
|
104
116
|
}
|
|
105
117
|
return null;
|
|
106
118
|
}, [ticket]);
|
|
107
|
-
return (_jsx(Frame, { children: _jsxs(Page, { title: ticket ? ticket.title : "Ticket detail", subtitle: ticket ? `Ticket #${ticket.id}` : "Loading", backAction: { content: "Tickets", onAction: () => window.history.back() }, children: [_jsxs(BlockStack, { gap: "400", children: [_jsx(ErrorBanner, { error: ticketQuery.error }), _jsx(ErrorBanner, { error: reply.error }), _jsx(ErrorBanner, { error: ratings.error }), _jsx(ErrorBanner, { error: statusError }), ticket ? (_jsx(Card, { children: _jsxs(BlockStack, { gap: "300", children: [_jsxs(InlineStack, { align: "space-between", blockAlign: "center", children: [_jsxs(InlineStack, { gap: "200", children: [_jsx(StatusBadge, { status: ticket.status }), _jsxs(Text, { as: "p", tone: "subdued", variant: "bodySm", children: ["Updated ",
|
|
119
|
+
return (_jsx(Frame, { children: _jsxs(Page, { title: ticket ? ticket.title : "Ticket detail", subtitle: ticket ? `Ticket #${ticket.id}` : "Loading", backAction: { content: "Tickets", onAction: () => window.history.back() }, children: [_jsxs(BlockStack, { gap: "400", children: [_jsx(ErrorBanner, { error: ticketQuery.error }), _jsx(ErrorBanner, { error: reply.error }), _jsx(ErrorBanner, { error: ratings.error }), _jsx(ErrorBanner, { error: statusError }), ticket ? (_jsx(Card, { children: _jsxs(BlockStack, { gap: "300", children: [_jsxs(InlineStack, { align: "space-between", blockAlign: "center", children: [_jsxs(InlineStack, { gap: "200", children: [_jsx(StatusBadge, { status: ticket.status }), _jsxs(Text, { as: "p", tone: "subdued", variant: "bodySm", children: ["Updated ", formatUnix(ticket.updatedAt, props.locale)] })] }), _jsxs(InlineStack, { gap: "200", children: [ticket.status !== "Closed" && ticket.status !== "Archived" ? (_jsx(Button, { tone: "critical", onClick: () => void updateTicketStatus("close"), children: "Close ticket" })) : null, ticket.status === "Closed" ? (_jsx(Button, { onClick: () => void updateTicketStatus("reopen"), children: "Reopen ticket" })) : null] })] }), _jsx(CountdownText, { dueAt: statusDueAt, label: ticket.status === "Closed" ? "Auto-archive in" : "Auto-close in" }), ticket.status === "Active" || ticket.status === "InProgress" ? (_jsx(Text, { as: "p", variant: "bodySm", tone: "subdued", children: `We usually reply within ${estimatedReplyMinutes(undefined)} minutes.` })) : null, assignedAlias ? (_jsx(Box, { borderWidth: "025", borderColor: "border", padding: "300", children: _jsxs(InlineStack, { gap: "300", blockAlign: "center", children: [_jsx(Avatar, { size: "md", name: assignedAlias.displayName, source: assignedAlias.avatarUrl ?? undefined }), _jsxs(BlockStack, { gap: "050", children: [_jsx(Text, { as: "p", variant: "bodyMd", fontWeight: "medium", children: assignedAlias.displayName }), _jsx(Text, { as: "p", variant: "bodySm", tone: "subdued", children: assignedAlias.department })] })] }) })) : null] }) })) : null, _jsx(Card, { children: _jsxs(BlockStack, { gap: "300", children: [_jsx(Text, { as: "h3", variant: "headingSm", children: "Message thread" }), messages.length === 0 ? (_jsx(Banner, { title: "No messages available yet", tone: "info", children: _jsx(Text, { as: "p", variant: "bodyMd", children: "Ticket messages will appear here after responses are posted." }) })) : (_jsx(BlockStack, { gap: "300", children: messages.map((message) => (_jsx(Box, { padding: "300", borderWidth: "025", borderColor: "border", children: _jsxs(BlockStack, { gap: "200", children: [_jsxs(InlineStack, { align: "space-between", blockAlign: "center", children: [_jsxs(InlineStack, { gap: "200", blockAlign: "center", children: [message.authorType === "agent" &&
|
|
120
|
+
message.authorAliasId &&
|
|
121
|
+
aliasMap.get(message.authorAliasId) ? (_jsx(Avatar, { size: "xs", name: aliasMap.get(message.authorAliasId).displayName, source: aliasMap.get(message.authorAliasId).avatarUrl ?? undefined })) : null, _jsx(Text, { as: "p", variant: "bodyMd", fontWeight: "medium", children: formatAuthor(message.authorType, message.authorAliasId
|
|
122
|
+
? aliasMap.get(message.authorAliasId)
|
|
123
|
+
: undefined) })] }), _jsx(Text, { as: "span", tone: "subdued", variant: "bodySm", children: formatUnix(message.createdAt, props.locale) })] }), _jsx(Text, { as: "p", variant: "bodyMd", children: message.bodyPlain }), message.authorType === "agent" ? (_jsxs(InlineGrid, { columns: { xs: "1fr", md: "120px 1fr auto" }, gap: "200", children: [_jsx(Select, { label: "Stars", labelHidden: true, options: STAR_OPTIONS, value: messageStars[message.id] ?? "5", onChange: (value) => setMessageStars((current) => ({
|
|
108
124
|
...current,
|
|
109
125
|
[message.id]: value
|
|
110
126
|
})) }), _jsx(TextField, { label: "Comment", labelHidden: true, value: messageComment[message.id] ?? "", onChange: (value) => setMessageComment((current) => ({
|
|
111
127
|
...current,
|
|
112
128
|
[message.id]: value
|
|
113
|
-
})), autoComplete: "off", placeholder: "Optional message feedback" }), _jsx(Button, { onClick: () => void submitMessageRating(message.id), children: "Rate message" })] })) : null] }) }, message.id))) }))] }) }), _jsx(Card, { children: _jsxs(BlockStack, { gap: "300", children: [_jsx(Text, { as: "h3", variant: "headingSm", children: "History" }), events.length === 0 ? (_jsx(Text, { as: "p", tone: "subdued", variant: "bodySm", children: "No visible events yet." })) : (_jsx(BlockStack, { gap: "200", children: events.map((event) => (_jsxs(BlockStack, { gap: "100", children: [_jsxs(InlineStack, { align: "space-between", children: [_jsx(Text, { as: "p", variant: "bodyMd", children: event.eventType }), _jsx(Text, { as: "span", tone: "subdued", variant: "bodySm", children:
|
|
129
|
+
})), autoComplete: "off", placeholder: "Optional message feedback" }), _jsx(Button, { onClick: () => void submitMessageRating(message.id), children: "Rate message" })] })) : null] }) }, message.id))) }))] }) }), _jsx(Card, { children: _jsxs(BlockStack, { gap: "300", children: [_jsx(Text, { as: "h3", variant: "headingSm", children: "History" }), events.length === 0 ? (_jsx(Text, { as: "p", tone: "subdued", variant: "bodySm", children: "No visible events yet." })) : (_jsx(BlockStack, { gap: "200", children: events.map((event) => (_jsxs(BlockStack, { gap: "100", children: [_jsxs(InlineStack, { align: "space-between", children: [_jsx(Text, { as: "p", variant: "bodyMd", children: event.eventType }), _jsx(Text, { as: "span", tone: "subdued", variant: "bodySm", children: formatUnix(event.createdAt, props.locale) })] }), event.payloadJson ? (_jsx(Text, { as: "p", tone: "subdued", variant: "bodySm", children: formatEventPayload(event.payloadJson) })) : null] }, event.id))) }))] }) }), _jsx(Card, { children: _jsxs(BlockStack, { gap: "300", children: [_jsx(Text, { as: "h3", variant: "headingSm", children: "Reply" }), _jsx(RichTextEditor, { label: "Reply body", value: replyBody, onChange: setReplyBody, helpText: "Supports lightweight rich formatting tokens for bold, italic, underline and links." }), _jsx(InlineStack, { align: "end", children: _jsx(Button, { onClick: () => void sendReply(), variant: "primary", loading: reply.loading, children: "Send reply" }) })] }) }), _jsx(Card, { children: _jsxs(BlockStack, { gap: "300", children: [_jsx(Text, { as: "h3", variant: "headingSm", children: "Ticket rating" }), _jsxs(InlineGrid, { columns: { xs: "1fr", md: "120px 1fr auto" }, gap: "200", children: [_jsx(Select, { label: "Stars", options: STAR_OPTIONS, value: ticketStars, onChange: setTicketStars }), _jsx(TextField, { label: "Comment", value: ticketComment, onChange: setTicketComment, autoComplete: "off", placeholder: "Optional ticket feedback" }), _jsx(Button, { onClick: () => void submitTicketRating(), loading: ratings.loading, children: "Submit rating" })] })] }) })] }), toastContent ? (_jsx(Toast, { content: toastContent, onDismiss: () => setToastContent(null) })) : null] }) }));
|
|
130
|
+
}
|
|
131
|
+
function estimatedReplyMinutes(priority) {
|
|
132
|
+
if (priority === "high")
|
|
133
|
+
return 10;
|
|
134
|
+
if (priority === "medium")
|
|
135
|
+
return 25;
|
|
136
|
+
return 45;
|
|
137
|
+
}
|
|
138
|
+
function formatUnix(value, locale) {
|
|
139
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value * 1000));
|
|
114
140
|
}
|
|
115
|
-
function formatAuthor(author) {
|
|
141
|
+
function formatAuthor(author, alias) {
|
|
116
142
|
switch (author) {
|
|
117
143
|
case "agent":
|
|
118
|
-
return "Support agent";
|
|
144
|
+
return alias ? alias.displayName : "Support agent";
|
|
119
145
|
case "admin":
|
|
120
146
|
return "Admin";
|
|
121
147
|
case "customer":
|
|
@@ -4,6 +4,7 @@ import type { HookClientOptions } from "../hooks/types";
|
|
|
4
4
|
interface SupportTicketListProps extends HookClientOptions {
|
|
5
5
|
initialStatus?: TicketStatus | "All";
|
|
6
6
|
onOpenTicket?: (ticketId: string) => void;
|
|
7
|
+
locale?: string;
|
|
7
8
|
}
|
|
8
9
|
export declare function SupportTicketList(props: SupportTicketListProps): React.ReactElement;
|
|
9
10
|
export {};
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { BlockStack, Box, Button, Card, Checkbox, EmptyState, Frame, InlineGrid, InlineStack, Page, Select, Spinner, Text, TextField, Toast } from "@shopify/polaris";
|
|
2
|
+
import { BlockStack, Box, Button, Card, Checkbox, EmptyState, Frame, InlineGrid, InlineStack, Page, Select, Spinner, Tabs, Text, TextField, Toast } from "@shopify/polaris";
|
|
3
3
|
import { useCallback, useMemo, useState } from "react";
|
|
4
4
|
import { useSupportClient } from "../hooks/useSupportClient";
|
|
5
5
|
import { useTickets } from "../hooks/useTickets";
|
|
6
|
+
import { usePortalConfig } from "../hooks/usePortalConfig";
|
|
6
7
|
import { CountdownText } from "./CountdownText";
|
|
7
8
|
import { ErrorBanner } from "./ErrorBanner";
|
|
8
9
|
import { StatusBadge } from "./StatusBadge";
|
|
@@ -22,6 +23,8 @@ const SORT_OPTIONS = [
|
|
|
22
23
|
];
|
|
23
24
|
export function SupportTicketList(props) {
|
|
24
25
|
const client = useSupportClient(props.client, props.clientOptions);
|
|
26
|
+
const [selectedTab, setSelectedTab] = useState(0);
|
|
27
|
+
const isArchivedTab = selectedTab === 1;
|
|
25
28
|
const [status, setStatus] = useState(props.initialStatus ?? "All");
|
|
26
29
|
const [search, setSearch] = useState("");
|
|
27
30
|
const [sort, setSort] = useState("updated_desc");
|
|
@@ -29,13 +32,22 @@ export function SupportTicketList(props) {
|
|
|
29
32
|
const [archiveError, setArchiveError] = useState(null);
|
|
30
33
|
const [toastContent, setToastContent] = useState(null);
|
|
31
34
|
const [archiving, setArchiving] = useState(false);
|
|
35
|
+
const effectiveStatus = isArchivedTab ? "Archived" : status;
|
|
32
36
|
const tickets = useTickets({
|
|
33
|
-
status,
|
|
37
|
+
status: effectiveStatus,
|
|
34
38
|
search,
|
|
35
39
|
sort
|
|
36
40
|
}, {
|
|
37
41
|
client
|
|
38
42
|
});
|
|
43
|
+
const portalConfig = usePortalConfig(props);
|
|
44
|
+
const categoryMap = useMemo(() => {
|
|
45
|
+
const map = {};
|
|
46
|
+
for (const cat of portalConfig.data?.categories ?? []) {
|
|
47
|
+
map[cat.id] = cat.name;
|
|
48
|
+
}
|
|
49
|
+
return map;
|
|
50
|
+
}, [portalConfig.data]);
|
|
39
51
|
const allItems = tickets.data ?? [];
|
|
40
52
|
const selectedTicketIds = useMemo(() => allItems.filter((ticket) => selectedIds[ticket.id]).map((ticket) => ticket.id), [allItems, selectedIds]);
|
|
41
53
|
const allChecked = allItems.length > 0 && selectedTicketIds.length === allItems.length;
|
|
@@ -63,6 +75,11 @@ export function SupportTicketList(props) {
|
|
|
63
75
|
return next;
|
|
64
76
|
});
|
|
65
77
|
}, []);
|
|
78
|
+
const handleTabChange = useCallback((index) => {
|
|
79
|
+
setSelectedTab(index);
|
|
80
|
+
setSelectedIds({});
|
|
81
|
+
setSearch("");
|
|
82
|
+
}, []);
|
|
66
83
|
const runBulkArchive = useCallback(async () => {
|
|
67
84
|
if (!hasSelection) {
|
|
68
85
|
return;
|
|
@@ -84,21 +101,30 @@ export function SupportTicketList(props) {
|
|
|
84
101
|
setArchiving(false);
|
|
85
102
|
}
|
|
86
103
|
}, [client, hasSelection, selectedTicketIds, tickets]);
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
104
|
+
const tabs = [
|
|
105
|
+
{ id: "active", content: "My Tickets" },
|
|
106
|
+
{ id: "archived", content: "Archived" }
|
|
107
|
+
];
|
|
108
|
+
return (_jsx(Frame, { children: _jsx(Page, { title: "Support tickets", subtitle: "Customer portal queue", children: _jsxs(BlockStack, { gap: "400", children: [_jsx(Card, { padding: "0", children: _jsx(Tabs, { tabs: tabs, selected: selectedTab, onSelect: handleTabChange, fitted: true, children: _jsx(Box, { padding: "400", children: _jsxs(BlockStack, { gap: "300", children: [_jsxs(InlineGrid, { columns: { xs: "1fr", md: isArchivedTab ? "1fr 1fr" : "2fr 1fr 1fr" }, gap: "300", children: [_jsx(TextField, { label: "Search", value: search, onChange: setSearch, placeholder: "Search by title or ticket id", autoComplete: "off" }), !isArchivedTab ? (_jsx(Select, { label: "Status", options: STATUS_OPTIONS.map((option) => ({
|
|
109
|
+
label: option.label,
|
|
110
|
+
value: option.value
|
|
111
|
+
})), value: status, onChange: (value) => setStatus(value) })) : null, _jsx(Select, { label: "Sort", options: SORT_OPTIONS, value: sort, onChange: (value) => setSort(value) })] }), !isArchivedTab ? (_jsxs(InlineStack, { align: "space-between", blockAlign: "center", children: [_jsx(Checkbox, { label: "Select all", checked: allChecked, onChange: toggleSelectAll }), _jsx(Button, { tone: "critical", disabled: !hasSelection || archiving, loading: archiving, onClick: () => void runBulkArchive(), children: "Archive selected" })] })) : null] }) }) }) }), _jsx(ErrorBanner, { error: tickets.error }), _jsx(ErrorBanner, { error: archiveError }), tickets.loading ? (_jsx(Card, { children: _jsx(InlineStack, { align: "center", children: _jsx(Spinner, { size: "large" }) }) })) : allItems.length === 0 ? (_jsx(Card, { children: _jsx(EmptyState, { heading: isArchivedTab ? "No archived tickets" : "No tickets match the current filters", action: isArchivedTab
|
|
112
|
+
? undefined
|
|
113
|
+
: {
|
|
114
|
+
content: "Reset filters",
|
|
115
|
+
onAction: () => {
|
|
116
|
+
setSearch("");
|
|
117
|
+
setStatus("All");
|
|
118
|
+
setSort("updated_desc");
|
|
119
|
+
}
|
|
120
|
+
}, image: "https://cdn.shopify.com/s/files/1/0262/4071/2726/files/emptystate-files.png", children: _jsx(Text, { as: "p", variant: "bodyMd", children: isArchivedTab
|
|
121
|
+
? "Archived tickets will appear here."
|
|
122
|
+
: "Try changing filters or search terms." }) }) })) : (_jsx(Card, { children: _jsxs(BlockStack, { gap: "0", children: [_jsx(RowHeader, {}), allItems.map((ticket) => (_jsx(Box, { padding: "300", borderBlockStartWidth: "025", borderColor: "border", children: _jsxs(InlineGrid, { columns: { xs: "auto 1fr 1fr 1fr 1fr" }, gap: "300", alignItems: "center", children: [_jsx(Checkbox, { label: `Select ${ticket.title}`, labelHidden: true, checked: Boolean(selectedIds[ticket.id]), onChange: (checked) => toggleTicket(ticket.id, checked) }), _jsx(StatusBadge, { status: ticket.status }), _jsxs(BlockStack, { gap: "100", children: [_jsx(Text, { as: "p", variant: "bodyMd", fontWeight: "medium", children: ticket.title }), _jsxs(Text, { as: "span", variant: "bodySm", tone: "subdued", children: ["#", ticket.id] }), ticket.categoryId !== null &&
|
|
123
|
+
categoryMap[ticket.categoryId] !== undefined ? (_jsx(Text, { as: "span", variant: "bodySm", tone: "subdued", children: categoryMap[ticket.categoryId] })) : null, props.onOpenTicket ? (_jsx(Button, { onClick: () => props.onOpenTicket?.(ticket.id), variant: "plain", children: "Open ticket" })) : null] }), _jsxs(BlockStack, { gap: "100", children: [_jsx(Text, { as: "p", variant: "bodyMd", children: formatUnix(ticket.lastResponseAt ?? ticket.updatedAt, props.locale) }), _jsx(CountdownText, { dueAt: ticket.autoActionDueAt, label: ticket.status === "Closed" ? "Auto-archive" : "Auto-close" })] }), _jsx(Text, { as: "p", variant: "bodyMd", children: ticket.attachmentCount > 0 ? ticket.attachmentCount : null })] }) }, ticket.id)))] }) })), toastContent ? (_jsx(Toast, { content: toastContent, onDismiss: () => setToastContent(null), duration: 2500 })) : null] }) }) }));
|
|
98
124
|
}
|
|
99
125
|
function RowHeader() {
|
|
100
126
|
return (_jsx(Box, { padding: "300", background: "bg-surface-secondary", children: _jsxs(InlineGrid, { columns: { xs: "auto 1fr 1fr 1fr 1fr" }, gap: "300", children: [_jsx(Text, { as: "p", variant: "bodySm", tone: "subdued", children: "Select" }), _jsx(Text, { as: "p", variant: "bodySm", tone: "subdued", children: "Status" }), _jsx(Text, { as: "p", variant: "bodySm", tone: "subdued", children: "Title" }), _jsx(Text, { as: "p", variant: "bodySm", tone: "subdued", children: "Last response" }), _jsx(Text, { as: "p", variant: "bodySm", tone: "subdued", children: "Attachments" })] }) }));
|
|
101
127
|
}
|
|
102
|
-
function formatUnix(value) {
|
|
103
|
-
return new Date(value * 1000)
|
|
128
|
+
function formatUnix(value, locale) {
|
|
129
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value * 1000));
|
|
104
130
|
}
|
|
@@ -1,11 +1,35 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { BlockStack, Button, ButtonGroup, Card, Text, TextField } from "@shopify/polaris";
|
|
3
|
-
import { useCallback } from "react";
|
|
3
|
+
import React, { useCallback } from "react";
|
|
4
4
|
export function RichTextEditor(props) {
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
|
|
5
|
+
const wrapperRef = React.useRef(null);
|
|
6
|
+
const applyFormat = useCallback((open, close, placeholder) => {
|
|
7
|
+
const textarea = wrapperRef.current?.querySelector("textarea") ?? null;
|
|
8
|
+
if (textarea) {
|
|
9
|
+
const start = textarea.selectionStart;
|
|
10
|
+
const end = textarea.selectionEnd;
|
|
11
|
+
const selected = props.value.slice(start, end);
|
|
12
|
+
const inner = selected.length > 0 ? selected : placeholder;
|
|
13
|
+
const newValue = props.value.slice(0, start) + open + inner + close + props.value.slice(end);
|
|
14
|
+
props.onChange(newValue);
|
|
15
|
+
requestAnimationFrame(() => {
|
|
16
|
+
textarea.focus();
|
|
17
|
+
textarea.setSelectionRange(start + open.length, start + open.length + inner.length);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
const snippet = `${open}${placeholder}${close}`;
|
|
22
|
+
const spacer = props.value.trim().length > 0 ? "\n\n" : "";
|
|
23
|
+
props.onChange(`${props.value}${spacer}${snippet}`);
|
|
24
|
+
}
|
|
9
25
|
}, [props]);
|
|
10
|
-
return (_jsx(Card, { children: _jsxs(BlockStack, { gap: "300", children: [_jsx(Text, { as: "h3", variant: "headingSm", children: props.label }),
|
|
26
|
+
return (_jsx(Card, { children: _jsxs(BlockStack, { gap: "300", children: [_jsx(Text, { as: "h3", variant: "headingSm", children: props.label }), _jsx(ButtonGroup, { children: [
|
|
27
|
+
{ label: "Bold", open: "**", close: "**", placeholder: "bold text" },
|
|
28
|
+
{ label: "Italic", open: "*", close: "*", placeholder: "italic text" },
|
|
29
|
+
{ label: "Underline", open: "<u>", close: "</u>", placeholder: "underlined text" },
|
|
30
|
+
{ label: "Link", open: "[", close: "](https://)", placeholder: "link text" }
|
|
31
|
+
].map(({ label, open, close, placeholder }) => (_jsx("span", { onMouseDown: (e) => {
|
|
32
|
+
// Prevent textarea from losing focus so the selection is preserved
|
|
33
|
+
e.preventDefault();
|
|
34
|
+
}, children: _jsx(Button, { onClick: () => applyFormat(open, close, placeholder), children: label }) }, label))) }), _jsx("div", { ref: wrapperRef, children: _jsx(TextField, { label: props.label, labelHidden: true, multiline: 8, value: props.value, onChange: props.onChange, autoComplete: "off", error: props.error, helpText: props.helpText }) })] }) }));
|
|
11
35
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Alias } from "@catandbox/schrodinger-contracts";
|
|
2
|
+
import type { QueryHookOptions, QueryHookState } from "./types";
|
|
3
|
+
interface PortalConfig {
|
|
4
|
+
categories: Array<{
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
}>;
|
|
8
|
+
aliases: Alias[];
|
|
9
|
+
}
|
|
10
|
+
export declare function usePortalConfig(options?: QueryHookOptions): QueryHookState<PortalConfig>;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { useSupportClient } from "./useSupportClient";
|
|
3
|
+
export function usePortalConfig(options = {}) {
|
|
4
|
+
const client = useSupportClient(options.client, options.clientOptions);
|
|
5
|
+
const enabled = options.enabled ?? true;
|
|
6
|
+
const [data, setData] = useState(null);
|
|
7
|
+
const [loading, setLoading] = useState(enabled);
|
|
8
|
+
const [error, setError] = useState(null);
|
|
9
|
+
const refresh = useCallback(async () => {
|
|
10
|
+
if (!enabled) {
|
|
11
|
+
setLoading(false);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
setLoading(true);
|
|
15
|
+
setError(null);
|
|
16
|
+
try {
|
|
17
|
+
const response = await client.getPortalConfig();
|
|
18
|
+
setData(response);
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
setError(err);
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
setLoading(false);
|
|
25
|
+
}
|
|
26
|
+
}, [client, enabled]);
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
void refresh();
|
|
29
|
+
}, [refresh]);
|
|
30
|
+
return useMemo(() => ({
|
|
31
|
+
data,
|
|
32
|
+
error,
|
|
33
|
+
loading,
|
|
34
|
+
refresh
|
|
35
|
+
}), [data, error, loading, refresh]);
|
|
36
|
+
}
|