Skip to content

Commit 99508bb

Browse files
committed
Raise a new request
1 parent 9a36782 commit 99508bb

22 files changed

+2408
-169
lines changed

app/api/contact-form/route.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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, { showHidden: false, depth: null, colors: true })
36+
);
37+
return new Response(upsertCustomerRes.error.message, {status: 500});
38+
}
39+
40+
console.log(`Customer upserted ${upsertCustomerRes.data.customer.id}`);
41+
42+
const createThreadRes = await plainClient.createThread({
43+
customerIdentifier: {
44+
customerId: upsertCustomerRes.data.customer.id,
45+
},
46+
title: body.title,
47+
tenantIdentifier: {externalId: TENANT_EXTERNAL_ID},
48+
components: [
49+
{
50+
componentText: {
51+
text: body.message,
52+
},
53+
},
54+
],
55+
});
56+
57+
if (createThreadRes.error) {
58+
console.error(inspect(createThreadRes.error, { showHidden: false, depth: null, colors: true }));
59+
return new Response(createThreadRes.error.message, {status: 500});
60+
}
61+
62+
console.log(`Thread created ${createThreadRes.data.id}.`);
63+
64+
65+
66+
return new Response('', {status: 200})
67+
}

app/layout.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import "./globals.css";
55
const inter = Inter({ subsets: ["latin"] });
66

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

1212
export default function RootLayout({

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: 93 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -7,111 +7,111 @@ import { fetchThreadTimelineEntries } from "@/lib/fetchThreadTimelineEntries";
77
import { plainClient } from "@/lib/plainClient";
88

99
export default async function ThreadPage({
10-
params,
10+
params,
1111
}: {
12-
params: { threadId: string };
12+
params: { threadId: string };
1313
}) {
14-
const threadId = params.threadId;
14+
const threadId = params.threadId;
1515

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

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

25-
const thread = data.thread;
26-
const timelineEntries = thread.timelineEntries;
25+
const thread = data.thread;
26+
const timelineEntries = thread.timelineEntries;
2727

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-
}
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+
}
4444

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-
}
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+
}
7272

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

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

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

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

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

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

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-
);
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+
);
117117
}

app/thread/new/page.module.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
.main {
2+
display: flex;
3+
flex-direction: column;
4+
gap: 32px;
5+
align-items: center;
6+
padding: 2rem;
7+
min-height: 100vh;
8+
}
9+
10+
.form {
11+
display: flex;
12+
flex-direction: column;
13+
gap: 16px;
14+
width: 100%;
15+
}

0 commit comments

Comments
 (0)