Skip to content

Commit f5022a8

Browse files
feat: Implement email templates
This commit introduces the email templates feature, allowing you to create reusable templates for broadcast and sequence emails. Backend: - Added `EmailTemplate` model. - Implemented GraphQL queries (`getEmailTemplate`, `getEmailTemplates`) and mutations (`createEmailTemplate`, `updateEmailTemplate`, `deleteEmailTemplate`) for email templates. - Added logic for the new GraphQL operations. Frontend: - Added a 'Templates' tab to the `/dashboard/mails` page. - Created a `TemplatesList` component to display email templates. - Created an email template editor page. - Created a new page for selecting a template when creating a new broadcast or sequence. - Updated the `createSequence` mutation to accept `title` and `content` from a template.
1 parent 7c1fa58 commit f5022a8

24 files changed

Lines changed: 1148 additions & 31 deletions

File tree

.husky/pre-commit

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
#!/usr/bin/env sh
22
. "$(dirname "$0")/_/husky.sh"
33

4-
pnpm exec lint-staged
4+
npx lint-staged

apps/docs/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"@docsearch/css": "^3.1.0",
1919
"@docsearch/react": "^3.1.0",
2020
"@types/node": "^18.0.0",
21-
"@types/react": "^17.0.45",
21+
"@types/react": "^18.0.0",
2222
"@types/react-dom": "^18.0.0",
2323
"astro": "^1.4.2",
2424
"preact": "^10.7.3",

apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx

Lines changed: 84 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { ThemeContext } from "@components/contexts";
3+
import { ServerConfigContext, ThemeContext } from "@components/contexts";
44
import {
55
Button,
66
Caption,
@@ -32,7 +32,8 @@ import {
3232
} from "@/ui-config/strings";
3333
import Link from "next/link";
3434
import { TriangleAlert } from "lucide-react";
35-
import { useRouter } from "next/navigation";
35+
import { useRecaptcha } from "@/hooks/use-recaptcha";
36+
import RecaptchaScriptLoader from "@/components/recaptcha-script-loader";
3637

3738
export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
3839
const { theme } = useContext(ThemeContext);
@@ -42,26 +43,98 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
4243
const [error, setError] = useState("");
4344
const [loading, setLoading] = useState(false);
4445
const { toast } = useToast();
45-
const router = useRouter();
46+
const serverConfig = useContext(ServerConfigContext);
47+
const { executeRecaptcha } = useRecaptcha();
4648

4749
const requestCode = async function (e: FormEvent) {
4850
e.preventDefault();
49-
const url = `/api/auth/code/generate?email=${encodeURIComponent(
50-
email,
51-
)}`;
51+
setLoading(true);
52+
setError("");
53+
54+
if (serverConfig.recaptchaSiteKey) {
55+
if (!executeRecaptcha) {
56+
toast({
57+
title: TOAST_TITLE_ERROR,
58+
description:
59+
"reCAPTCHA service not available. Please try again later.",
60+
variant: "destructive",
61+
});
62+
setLoading(false);
63+
return;
64+
}
65+
66+
const recaptchaToken = await executeRecaptcha("login_code_request");
67+
if (!recaptchaToken) {
68+
toast({
69+
title: TOAST_TITLE_ERROR,
70+
description:
71+
"reCAPTCHA validation failed. Please try again.",
72+
variant: "destructive",
73+
});
74+
setLoading(false);
75+
return;
76+
}
77+
try {
78+
const recaptchaVerificationResponse = await fetch(
79+
"/api/recaptcha",
80+
{
81+
method: "POST",
82+
headers: { "Content-Type": "application/json" },
83+
body: JSON.stringify({ token: recaptchaToken }),
84+
},
85+
);
86+
87+
const recaptchaData =
88+
await recaptchaVerificationResponse.json();
89+
90+
if (
91+
!recaptchaVerificationResponse.ok ||
92+
!recaptchaData.success ||
93+
(recaptchaData.score && recaptchaData.score < 0.5)
94+
) {
95+
toast({
96+
title: TOAST_TITLE_ERROR,
97+
description: `reCAPTCHA verification failed. ${recaptchaData.score ? `Score: ${recaptchaData.score.toFixed(2)}.` : ""} Please try again.`,
98+
variant: "destructive",
99+
});
100+
setLoading(false);
101+
return;
102+
}
103+
} catch (err) {
104+
console.error("Error during reCAPTCHA verification:", err);
105+
toast({
106+
title: TOAST_TITLE_ERROR,
107+
description:
108+
"reCAPTCHA verification failed. Please try again.",
109+
variant: "destructive",
110+
});
111+
setLoading(false);
112+
return;
113+
}
114+
}
115+
52116
try {
53-
setLoading(true);
117+
const url = `/api/auth/code/generate?email=${encodeURIComponent(
118+
email,
119+
)}`;
54120
const response = await fetch(url);
55121
const resp = await response.json();
56122
if (response.ok) {
57123
setShowCode(true);
58124
} else {
59125
toast({
60126
title: TOAST_TITLE_ERROR,
61-
description: resp.error,
127+
description: resp.error || "Failed to request code.",
62128
variant: "destructive",
63129
});
64130
}
131+
} catch (err) {
132+
console.error("Error during requestCode:", err);
133+
toast({
134+
title: TOAST_TITLE_ERROR,
135+
description: "An unexpected error occurred. Please try again.",
136+
variant: "destructive",
137+
});
65138
} finally {
66139
setLoading(false);
67140
}
@@ -79,11 +152,6 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
79152
if (response?.error) {
80153
setError(`Can't sign you in at this time`);
81154
} else {
82-
// toast({
83-
// title: TOAST_TITLE_SUCCESS,
84-
// description: LOGIN_SUCCESS,
85-
// });
86-
// router.replace(redirectTo || "/dashboard/my-content");
87155
window.location.href = redirectTo || "/dashboard/my-content";
88156
}
89157
} finally {
@@ -99,7 +167,8 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
99167
{error && (
100168
<div
101169
style={{
102-
color: theme?.theme?.colors?.error,
170+
color: theme?.theme?.colors?.light
171+
?.destructive,
103172
}}
104173
className="flex items-center gap-2 mb-4"
105174
>
@@ -218,6 +287,7 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
218287
</div>
219288
</div>
220289
</div>
290+
<RecaptchaScriptLoader />
221291
</Section>
222292
);
223293
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"use client";
2+
3+
import {
4+
Address,
5+
EmailTemplate,
6+
SequenceType,
7+
} from "@courselit/common-models";
8+
import { useToast } from "@courselit/components-library";
9+
import { AppDispatch, AppState } from "@courselit/state-management";
10+
import { networkAction } from "@courselit/state-management/dist/action-creators";
11+
import { FetchBuilder } from "@courselit/utils";
12+
import {
13+
TOAST_TITLE_ERROR,
14+
} from "@ui-config/strings";
15+
import { useEffect, useState } from "react";
16+
import {
17+
Card,
18+
CardContent,
19+
CardHeader,
20+
CardTitle,
21+
} from "@/components/ui/card";
22+
import { useRouter, useSearchParams } from "next/navigation";
23+
import { ThunkDispatch } from "redux-thunk";
24+
import { AnyAction } from "redux";
25+
import { AddressContext } from "@components/contexts";
26+
import { useContext } from "react";
27+
28+
interface NewMailPageClientProps {
29+
systemTemplates: EmailTemplate[];
30+
}
31+
32+
const NewMailPageClient = ({ systemTemplates }: NewMailPageClientProps) => {
33+
const address = useContext(AddressContext);
34+
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
35+
const [isLoading, setIsLoading] = useState(false);
36+
const { toast } = useToast();
37+
const router = useRouter();
38+
const searchParams = useSearchParams();
39+
const dispatch = () => {};
40+
41+
const type = searchParams?.get("type") as SequenceType;
42+
43+
const fetch = new FetchBuilder()
44+
.setUrl(`${address.backend}/api/graph`)
45+
.setIsGraphQLEndpoint(true);
46+
47+
useEffect(() => {
48+
loadTemplates();
49+
}, []);
50+
51+
const loadTemplates = async () => {
52+
setIsLoading(true);
53+
const query = `
54+
query GetEmailTemplates {
55+
templates: getEmailTemplates {
56+
templateId
57+
title
58+
content {
59+
content {
60+
blockType
61+
settings
62+
}
63+
style
64+
meta
65+
}
66+
}
67+
}`;
68+
69+
const fetcher = fetch
70+
.setPayload({
71+
query,
72+
})
73+
.build();
74+
75+
try {
76+
dispatch && dispatch(networkAction(true));
77+
const response = await fetcher.exec();
78+
if (response.templates) {
79+
setTemplates(response.templates);
80+
}
81+
} catch (e: any) {
82+
toast({
83+
title: TOAST_TITLE_ERROR,
84+
description: e.message,
85+
variant: "destructive",
86+
});
87+
} finally {
88+
dispatch && dispatch(networkAction(false));
89+
setIsLoading(false);
90+
}
91+
};
92+
93+
const createSequence = async (template: EmailTemplate) => {
94+
const mutation = `
95+
mutation createSequence(
96+
$type: SequenceType!,
97+
$title: String!,
98+
$content: String!
99+
) {
100+
sequence: createSequence(type: $type, title: $title, content: $content) {
101+
sequenceId
102+
}
103+
}
104+
`;
105+
const fetch = new FetchBuilder()
106+
.setUrl(`${address.backend}/api/graph`)
107+
.setPayload({
108+
query: mutation,
109+
variables: {
110+
type: type.toUpperCase(),
111+
title: template.title,
112+
content: JSON.stringify(template.content),
113+
},
114+
})
115+
.setIsGraphQLEndpoint(true)
116+
.build();
117+
try {
118+
dispatch &&
119+
(dispatch as ThunkDispatch<AppState, null, AnyAction>)(
120+
networkAction(true),
121+
);
122+
const response = await fetch.exec();
123+
if (response.sequence && response.sequence.sequenceId) {
124+
router.push(
125+
`/dashboard/mails/${type}/${response.sequence.sequenceId}`,
126+
);
127+
}
128+
} catch (err) {
129+
toast({
130+
title: TOAST_TITLE_ERROR,
131+
description: err.message,
132+
variant: "destructive",
133+
});
134+
} finally {
135+
dispatch &&
136+
(dispatch as ThunkDispatch<AppState, null, AnyAction>)(
137+
networkAction(false),
138+
);
139+
}
140+
};
141+
142+
const onTemplateClick = (template: EmailTemplate) => {
143+
createSequence(template);
144+
};
145+
146+
return (
147+
<div className="p-8">
148+
<h1 className="text-4xl font-semibold mb-8">Choose a template</h1>
149+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
150+
{[...systemTemplates, ...templates].map((template) => (
151+
<Card
152+
key={template.templateId}
153+
className="cursor-pointer hover:shadow-lg transition-shadow"
154+
onClick={() => onTemplateClick(template)}
155+
>
156+
<CardHeader>
157+
<CardTitle>{template.title}</CardTitle>
158+
</CardHeader>
159+
<CardContent>
160+
<div className="h-48 bg-gray-200 flex items-center justify-center">
161+
<p className="text-gray-500">Preview</p>
162+
</div>
163+
</CardContent>
164+
</Card>
165+
))}
166+
</div>
167+
</div>
168+
);
169+
};
170+
171+
export default NewMailPageClient;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { promises as fs } from "fs";
2+
import path from "path";
3+
import { EmailTemplate } from "@courselit/common-models";
4+
import NewMailPageClient from "./new-mail-page-client";
5+
6+
async function getSystemTemplates(): Promise<EmailTemplate[]> {
7+
const templatesDir = path.join(
8+
process.cwd(),
9+
"apps/web/templates/system-emails",
10+
);
11+
const filenames = await fs.readdir(templatesDir);
12+
13+
const templates = filenames.map(async (filename) => {
14+
const filePath = path.join(templatesDir, filename);
15+
const fileContents = await fs.readFile(filePath, "utf8");
16+
return JSON.parse(fileContents);
17+
});
18+
19+
return Promise.all(templates);
20+
}
21+
22+
export default async function NewMailPage() {
23+
const systemTemplates = await getSystemTemplates();
24+
25+
return <NewMailPageClient systemTemplates={systemTemplates} />;
26+
}

0 commit comments

Comments
 (0)