@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.
@@ -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: unknown[];
81
+ aliases: Alias[];
82
82
  }>;
83
83
  listTickets(params?: ListTicketsParams): Promise<{
84
84
  items: Ticket[];
@@ -51,8 +51,18 @@ export class SupportApiClient {
51
51
  });
52
52
  }
53
53
  async getTicket(ticketId) {
54
- const payload = await this.requestJson("GET", `/tickets/${encodeURIComponent(ticketId)}`);
55
- return normalizeTicketDetailPayload(payload);
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 ", new Date(ticket.updatedAt * 1000).toLocaleString()] })] }), _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" })] }) })) : 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: [_jsx(Text, { as: "p", variant: "bodyMd", fontWeight: "medium", children: formatAuthor(message.authorType) }), _jsx(Text, { as: "span", tone: "subdued", variant: "bodySm", children: new Date(message.createdAt * 1000).toLocaleString() })] }), _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) => ({
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: new Date(event.createdAt * 1000).toLocaleString() })] }), 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] }) }));
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
- return (_jsx(Frame, { children: _jsx(Page, { title: "Support tickets", subtitle: "Customer portal queue", children: _jsxs(BlockStack, { gap: "400", children: [_jsx(Card, { children: _jsxs(BlockStack, { gap: "300", children: [_jsxs(InlineGrid, { columns: { xs: "1fr", md: "2fr 1fr 1fr" }, gap: "300", children: [_jsx(TextField, { label: "Search", value: search, onChange: setSearch, placeholder: "Search by title or ticket id", autoComplete: "off" }), _jsx(Select, { label: "Status", options: STATUS_OPTIONS.map((option) => ({
88
- label: option.label,
89
- value: option.value
90
- })), value: status, onChange: (value) => setStatus(value) }), _jsx(Select, { label: "Sort", options: SORT_OPTIONS, value: sort, onChange: (value) => setSort(value) })] }), _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" })] })] }) }), _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: "No tickets match the current filters", action: {
91
- content: "Reset filters",
92
- onAction: () => {
93
- setSearch("");
94
- setStatus("All");
95
- setSort("updated_desc");
96
- }
97
- }, image: "https://cdn.shopify.com/s/files/1/0262/4071/2726/files/emptystate-files.png", children: _jsx(Text, { as: "p", variant: "bodyMd", children: "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] }), 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) }), _jsx(CountdownText, { dueAt: ticket.autoActionDueAt, label: ticket.status === "Closed" ? "Auto-archive" : "Auto-close" })] }), _jsx(Text, { as: "p", variant: "bodyMd", children: ticket.attachmentCount })] }) }, ticket.id)))] }) })), toastContent ? (_jsx(Toast, { content: toastContent, onDismiss: () => setToastContent(null), duration: 2500 })) : null] }) }) }));
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).toLocaleString();
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 appendFormat = useCallback((open, close, placeholder) => {
6
- const snippet = `${open}${placeholder}${close}`;
7
- const spacer = props.value.trim().length > 0 ? "\n\n" : "";
8
- props.onChange(`${props.value}${spacer}${snippet}`);
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 }), _jsxs(ButtonGroup, { children: [_jsx(Button, { onClick: () => appendFormat("**", "**", "bold text"), children: "Bold" }), _jsx(Button, { onClick: () => appendFormat("*", "*", "italic text"), children: "Italic" }), _jsx(Button, { onClick: () => appendFormat("<u>", "</u>", "underlined text"), children: "Underline" }), _jsx(Button, { onClick: () => appendFormat("[", "](https://example.com)", "link text"), children: "Link" })] }), _jsx(TextField, { label: props.label, labelHidden: true, multiline: 8, value: props.value, onChange: props.onChange, autoComplete: "off", error: props.error, helpText: props.helpText })] }) }));
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catandbox/schrodinger-shopify-adapter",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",