Skip to content

Commit cac0d6d

Browse files
authored
Merge pull request #3 from team-plain/p-7889-submit-a-ticket
Raise a new request
2 parents 9a36782 + 752a7b3 commit cac0d6d

22 files changed

+565
-167
lines changed

app/api/contact-form/route.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { TENANT_EXTERNAL_ID } from "@/app/page";
2+
import { plainClient } from "@/lib/plainClient";
3+
import { inspect } from "util";
4+
5+
export type RequestBody = {
6+
title: string;
7+
message: string;
8+
};
9+
10+
// When implementing this for real, take these values from user auth (e.g validate auth token and take values from claims)
11+
const name = "Bob Smith";
12+
const email = "bob.smith@example.com";
13+
14+
export async function POST(request: Request) {
15+
// In production validation of the request body might be necessary.
16+
const body = await request.json();
17+
18+
const upsertCustomerRes = await plainClient.upsertCustomer({
19+
identifier: {
20+
emailAddress: email,
21+
},
22+
onCreate: {
23+
fullName: name,
24+
email: {
25+
email: email,
26+
isVerified: true,
27+
},
28+
tenantIdentifiers: [{ externalId: TENANT_EXTERNAL_ID }],
29+
},
30+
onUpdate: {},
31+
});
32+
33+
if (upsertCustomerRes.error) {
34+
console.error(
35+
inspect(upsertCustomerRes.error, {
36+
showHidden: false,
37+
depth: null,
38+
colors: true,
39+
})
40+
);
41+
return new Response(upsertCustomerRes.error.message, { status: 500 });
42+
}
43+
44+
console.log(`Customer upserted ${upsertCustomerRes.data.customer.id}`);
45+
46+
const createThreadRes = await plainClient.createThread({
47+
customerIdentifier: {
48+
customerId: upsertCustomerRes.data.customer.id,
49+
},
50+
title: body.title,
51+
tenantIdentifier: { externalId: TENANT_EXTERNAL_ID },
52+
components: [
53+
{
54+
componentText: {
55+
text: body.message,
56+
},
57+
},
58+
],
59+
});
60+
61+
if (createThreadRes.error) {
62+
console.error(
63+
inspect(createThreadRes.error, {
64+
showHidden: false,
65+
depth: null,
66+
colors: true,
67+
})
68+
);
69+
return new Response(createThreadRes.error.message, { status: 500 });
70+
}
71+
72+
console.log(`Thread created ${createThreadRes.data.id}.`);
73+
return new Response("", { status: 200 });
74+
}

app/layout.tsx

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,28 @@
11
import type { Metadata } from "next";
22
import { Inter } from "next/font/google";
33
import "./globals.css";
4+
import { Toaster } from "react-hot-toast";
45

56
const inter = Inter({ subsets: ["latin"] });
67

78
export const metadata: Metadata = {
8-
title: "Create Next App",
9-
description: "Generated by create next app",
9+
title: "Plain support portal",
10+
description: "Plain support portal example repo",
1011
};
1112

1213
export default function RootLayout({
13-
children,
14+
children,
1415
}: Readonly<{
15-
children: React.ReactNode;
16+
children: React.ReactNode;
1617
}>) {
17-
return (
18-
<html lang="en">
19-
<body className={inter.className}>{children}</body>
20-
</html>
21-
);
18+
return (
19+
<html lang="en">
20+
<body className={inter.className}>
21+
<>
22+
{children}
23+
<Toaster />
24+
</>
25+
</body>
26+
</html>
27+
);
2228
}

app/page.tsx

Lines changed: 33 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,42 +7,42 @@ import { PaginationControls } from "@/components/paginationControls";
77
export const fetchCache = "force-no-store";
88

99
// When adapting this example get the tenant id as part of auth or fetch it afterwards
10-
const TENANT_EXTERNAL_ID = "abcd1234";
10+
export const TENANT_EXTERNAL_ID = "abcd1234";
1111

1212
export default async function Home({
13-
searchParams,
13+
searchParams,
1414
}: {
15-
searchParams: { [key: string]: string | undefined };
15+
searchParams: { [key: string]: string | undefined };
1616
}) {
17-
const threads = await plainClient.getThreads({
18-
filters: {
19-
// If you want to only allow customers to view threads they have raised then you can filter by customerIds instead.
20-
// Note that if you provide multiple filters they are combined with AND rather than OR.
21-
// customerIds: ["c_01J28ZQKJX9CVRXVHBMAXNSV5G"],
22-
tenantIdentifiers: [{ externalId: TENANT_EXTERNAL_ID }],
23-
},
24-
after: searchParams.after as string | undefined,
25-
before: searchParams.before as string | undefined,
26-
});
17+
const threads = await plainClient.getThreads({
18+
filters: {
19+
// If you want to only allow customers to view threads they have raised then you can filter by customerIds instead.
20+
// Note that if you provide multiple filters they are combined with AND rather than OR.
21+
// customerIds: ["c_01J28ZQKJX9CVRXVHBMAXNSV5G"],
22+
tenantIdentifiers: [{ externalId: TENANT_EXTERNAL_ID }],
23+
},
24+
after: searchParams.after as string | undefined,
25+
before: searchParams.before as string | undefined,
26+
});
2727

28-
return (
29-
<>
30-
<Navigation title="Plain Headless Portal example" />
31-
<main className={styles.main}>
32-
<h2>Support requests</h2>
33-
{threads.data && (
34-
<>
35-
<div className={styles.list}>
36-
{threads.data?.threads.map((thread) => {
37-
return (
38-
<ThreadRow thread={thread} key={`thread-row-${thread.id}`} />
39-
);
40-
})}
41-
</div>
42-
<PaginationControls pageInfo={threads.data.pageInfo} />
43-
</>
44-
)}
45-
</main>
46-
</>
47-
);
28+
return (
29+
<>
30+
<Navigation title="Plain Headless Portal example" />
31+
<main className={styles.main}>
32+
<h2>Support requests</h2>
33+
{threads.data && (
34+
<>
35+
<div className={styles.list}>
36+
{threads.data.threads.map((thread) => {
37+
return (
38+
<ThreadRow thread={thread} key={`thread-row-${thread.id}`} />
39+
);
40+
})}
41+
</div>
42+
<PaginationControls pageInfo={threads.data.pageInfo} />
43+
</>
44+
)}
45+
</main>
46+
</>
47+
);
4848
}

app/thread/[threadId]/page.tsx

Lines changed: 95 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -4,114 +4,115 @@ import { getActorFullName } from "@/lib/getActorFullName";
44
import { getFormattedDate } from "@/lib/getFormattedDate";
55
import { getPriority } from "@/lib/getPriority";
66
import { fetchThreadTimelineEntries } from "@/lib/fetchThreadTimelineEntries";
7-
import { plainClient } from "@/lib/plainClient";
87

98
export default async function ThreadPage({
10-
params,
9+
params,
1110
}: {
12-
params: { threadId: string };
11+
params: { threadId: string };
1312
}) {
14-
const threadId = params.threadId;
13+
const threadId = params.threadId;
1514

16-
const { data } = await fetchThreadTimelineEntries({
17-
threadId,
18-
first: 100,
19-
});
15+
const { data } = await fetchThreadTimelineEntries({
16+
threadId,
17+
first: 100,
18+
});
2019

21-
if (!data) {
22-
return null;
23-
}
20+
if (!data) {
21+
return null;
22+
}
2423

25-
const thread = data.thread;
26-
const timelineEntries = thread.timelineEntries;
24+
const thread = data.thread;
25+
const timelineEntries = thread.timelineEntries;
2726

28-
return (
29-
<>
30-
<Navigation hasBackButton title={thread.title} />
31-
<main className={styles.main}>
32-
<div className={styles.timeline}>
33-
{timelineEntries.edges.reverse().map((e) => {
34-
const entry = e.node;
35-
if (
36-
entry.entry.__typename !== "CustomEntry" &&
37-
entry.entry.__typename !== "EmailEntry" &&
38-
entry.entry.__typename !== "SlackReplyEntry" &&
39-
entry.entry.__typename !== "SlackMessageEntry" &&
40-
entry.entry.__typename !== "ChatEntry"
41-
) {
42-
return null;
43-
}
27+
return (
28+
<>
29+
<Navigation hasBackButton title={thread.title} />
30+
<main className={styles.main}>
31+
<div className={styles.timeline}>
32+
{timelineEntries.edges.reverse().map((e, idx) => {
33+
const entry = e.node;
34+
if (
35+
entry.entry.__typename !== "CustomEntry" &&
36+
entry.entry.__typename !== "EmailEntry" &&
37+
entry.entry.__typename !== "SlackReplyEntry" &&
38+
entry.entry.__typename !== "SlackMessageEntry" &&
39+
entry.entry.__typename !== "ChatEntry"
40+
) {
41+
return null;
42+
}
43+
const actorName =
44+
entry.actor.__typename === "MachineUserActor" && idx === 0
45+
? thread.customer.fullName
46+
: getActorFullName(entry.actor);
4447

45-
return (
46-
<div className={styles.message} key={entry.id}>
47-
<div className={styles.entryHeader}>
48-
<div className={styles.avatar}>
49-
{getActorFullName(entry.actor)[0].toUpperCase()}
50-
</div>
51-
<div>
52-
<div className={styles.actor}>
53-
{getActorFullName(entry.actor)}
54-
</div>
55-
<div className={styles.timestamp}>
56-
{getFormattedDate(entry.timestamp.iso8601)}
57-
</div>
58-
</div>
59-
</div>
60-
{entry.entry.__typename === "CustomEntry" &&
61-
entry.entry.components.map((component, idx) => {
62-
if (component.__typename === "ComponentText") {
63-
return (
64-
<div
65-
key={`comp_${component.text}`}
66-
className={styles.component}
67-
>
68-
{component.text}
69-
</div>
70-
);
71-
}
48+
return (
49+
<div className={styles.message} key={entry.id}>
50+
<div className={styles.entryHeader}>
51+
<div className={styles.avatar}>
52+
{actorName[0].toUpperCase()}
53+
</div>
54+
<div>
55+
<div className={styles.actor}>{actorName}</div>
56+
<div className={styles.timestamp}>
57+
{getFormattedDate(entry.timestamp.iso8601)}
58+
</div>
59+
</div>
60+
</div>
61+
{entry.entry.__typename === "CustomEntry" &&
62+
entry.entry.components.map((component, idx) => {
63+
if (component.__typename === "ComponentText") {
64+
return (
65+
<div
66+
key={`comp_${component.text}`}
67+
className={styles.component}
68+
>
69+
{component.text}
70+
</div>
71+
);
72+
}
7273

73-
return null;
74-
})}
75-
{entry.entry.__typename === "ChatEntry" && (
76-
<div>{entry.entry.text}</div>
77-
)}
78-
</div>
79-
);
80-
})}
81-
</div>
74+
return null;
75+
})}
76+
{entry.entry.__typename === "ChatEntry" && (
77+
<div>{entry.entry.text}</div>
78+
)}
79+
</div>
80+
);
81+
})}
82+
</div>
8283

83-
<div className={styles.threadInfo}>
84-
<div className={styles.title}>{thread.title}</div>
85-
<div className={styles.description}>{thread.description}</div>
84+
<div className={styles.threadInfo}>
85+
<div className={styles.title}>{thread.title}</div>
86+
<div className={styles.description}>{thread.description}</div>
8687

87-
<div className={styles.threadInfoGrid}>
88-
<div className={styles.threadInfoProp}>Opened by:</div>
89-
<div className={styles.threadInfoDesc}>
90-
{getActorFullName(thread.createdBy)}
91-
</div>
88+
<div className={styles.threadInfoGrid}>
89+
<div className={styles.threadInfoProp}>Opened by:</div>
90+
<div className={styles.threadInfoDesc}>
91+
{thread.customer.fullName}
92+
</div>
9293

93-
<div className={styles.threadInfoProp}>Opened:</div>
94-
<div className={styles.threadInfoDesc}>
95-
{getFormattedDate(thread.createdAt.iso8601)}
96-
</div>
94+
<div className={styles.threadInfoProp}>Opened:</div>
95+
<div className={styles.threadInfoDesc}>
96+
{getFormattedDate(thread.createdAt.iso8601)}
97+
</div>
9798

98-
<div className={styles.threadInfoProp}>Last activity:</div>
99-
<div className={styles.threadInfoDesc}>
100-
{getFormattedDate(thread.updatedAt.iso8601)}
101-
</div>
99+
<div className={styles.threadInfoProp}>Last activity:</div>
100+
<div className={styles.threadInfoDesc}>
101+
{getFormattedDate(thread.updatedAt.iso8601)}
102+
</div>
102103

103-
<div className={styles.threadInfoProp}>Status:</div>
104-
<div className={styles.threadInfoDesc}>
105-
In {thread.status.toLowerCase()} queue
106-
</div>
104+
<div className={styles.threadInfoProp}>Status:</div>
105+
<div className={styles.threadInfoDesc}>
106+
In {thread.status.toLowerCase()} queue
107+
</div>
107108

108-
<div className={styles.threadInfoProp}>Priority:</div>
109-
<div className={styles.threadInfoDesc}>
110-
{getPriority(thread.priority)}
111-
</div>
112-
</div>
113-
</div>
114-
</main>
115-
</>
116-
);
109+
<div className={styles.threadInfoProp}>Priority:</div>
110+
<div className={styles.threadInfoDesc}>
111+
{getPriority(thread.priority)}
112+
</div>
113+
</div>
114+
</div>
115+
</main>
116+
</>
117+
);
117118
}

0 commit comments

Comments
 (0)