diff --git a/.husky/pre-commit b/.husky/pre-commit
index 770b781fb..25d223573 100755
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -1,4 +1,4 @@
#!/usr/bin/env sh
. "$(dirname "$0")/_/husky.sh"
-pnpm exec lint-staged
\ No newline at end of file
+npx lint-staged
\ No newline at end of file
diff --git a/apps/docs/package.json b/apps/docs/package.json
index a9d20749c..3c6d0e15d 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -18,7 +18,7 @@
"@docsearch/css": "^3.1.0",
"@docsearch/react": "^3.1.0",
"@types/node": "^18.0.0",
- "@types/react": "^17.0.45",
+ "@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"astro": "^1.4.2",
"preact": "^10.7.3",
diff --git a/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx b/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx
index 119224591..fad10c976 100644
--- a/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx
+++ b/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx
@@ -1,6 +1,6 @@
"use client";
-import { ThemeContext } from "@components/contexts";
+import { ServerConfigContext, ThemeContext } from "@components/contexts";
import {
Button,
Caption,
@@ -32,7 +32,8 @@ import {
} from "@/ui-config/strings";
import Link from "next/link";
import { TriangleAlert } from "lucide-react";
-import { useRouter } from "next/navigation";
+import { useRecaptcha } from "@/hooks/use-recaptcha";
+import RecaptchaScriptLoader from "@/components/recaptcha-script-loader";
export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
const { theme } = useContext(ThemeContext);
@@ -42,15 +43,80 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const { toast } = useToast();
- const router = useRouter();
+ const serverConfig = useContext(ServerConfigContext);
+ const { executeRecaptcha } = useRecaptcha();
const requestCode = async function (e: FormEvent) {
e.preventDefault();
- const url = `/api/auth/code/generate?email=${encodeURIComponent(
- email,
- )}`;
+ setLoading(true);
+ setError("");
+
+ if (serverConfig.recaptchaSiteKey) {
+ if (!executeRecaptcha) {
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description:
+ "reCAPTCHA service not available. Please try again later.",
+ variant: "destructive",
+ });
+ setLoading(false);
+ return;
+ }
+
+ const recaptchaToken = await executeRecaptcha("login_code_request");
+ if (!recaptchaToken) {
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description:
+ "reCAPTCHA validation failed. Please try again.",
+ variant: "destructive",
+ });
+ setLoading(false);
+ return;
+ }
+ try {
+ const recaptchaVerificationResponse = await fetch(
+ "/api/recaptcha",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: recaptchaToken }),
+ },
+ );
+
+ const recaptchaData =
+ await recaptchaVerificationResponse.json();
+
+ if (
+ !recaptchaVerificationResponse.ok ||
+ !recaptchaData.success ||
+ (recaptchaData.score && recaptchaData.score < 0.5)
+ ) {
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description: `reCAPTCHA verification failed. ${recaptchaData.score ? `Score: ${recaptchaData.score.toFixed(2)}.` : ""} Please try again.`,
+ variant: "destructive",
+ });
+ setLoading(false);
+ return;
+ }
+ } catch (err) {
+ console.error("Error during reCAPTCHA verification:", err);
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description:
+ "reCAPTCHA verification failed. Please try again.",
+ variant: "destructive",
+ });
+ setLoading(false);
+ return;
+ }
+ }
+
try {
- setLoading(true);
+ const url = `/api/auth/code/generate?email=${encodeURIComponent(
+ email,
+ )}`;
const response = await fetch(url);
const resp = await response.json();
if (response.ok) {
@@ -58,10 +124,17 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
} else {
toast({
title: TOAST_TITLE_ERROR,
- description: resp.error,
+ description: resp.error || "Failed to request code.",
variant: "destructive",
});
}
+ } catch (err) {
+ console.error("Error during requestCode:", err);
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description: "An unexpected error occurred. Please try again.",
+ variant: "destructive",
+ });
} finally {
setLoading(false);
}
@@ -79,11 +152,6 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
if (response?.error) {
setError(`Can't sign you in at this time`);
} else {
- // toast({
- // title: TOAST_TITLE_SUCCESS,
- // description: LOGIN_SUCCESS,
- // });
- // router.replace(redirectTo || "/dashboard/my-content");
window.location.href = redirectTo || "/dashboard/my-content";
}
} finally {
@@ -99,7 +167,8 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
{error && (
@@ -218,6 +287,7 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
+
);
}
diff --git a/apps/web/app/(with-contexts)/dashboard/mails/new/new-mail-page-client.tsx b/apps/web/app/(with-contexts)/dashboard/mails/new/new-mail-page-client.tsx
new file mode 100644
index 000000000..ffda0f4d7
--- /dev/null
+++ b/apps/web/app/(with-contexts)/dashboard/mails/new/new-mail-page-client.tsx
@@ -0,0 +1,171 @@
+"use client";
+
+import {
+ Address,
+ EmailTemplate,
+ SequenceType,
+} from "@courselit/common-models";
+import { useToast } from "@courselit/components-library";
+import { AppDispatch, AppState } from "@courselit/state-management";
+import { networkAction } from "@courselit/state-management/dist/action-creators";
+import { FetchBuilder } from "@courselit/utils";
+import {
+ TOAST_TITLE_ERROR,
+} from "@ui-config/strings";
+import { useEffect, useState } from "react";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { useRouter, useSearchParams } from "next/navigation";
+import { ThunkDispatch } from "redux-thunk";
+import { AnyAction } from "redux";
+import { AddressContext } from "@components/contexts";
+import { useContext } from "react";
+
+interface NewMailPageClientProps {
+ systemTemplates: EmailTemplate[];
+}
+
+const NewMailPageClient = ({ systemTemplates }: NewMailPageClientProps) => {
+ const address = useContext(AddressContext);
+ const [templates, setTemplates] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const { toast } = useToast();
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const dispatch = () => {};
+
+ const type = searchParams?.get("type") as SequenceType;
+
+ const fetch = new FetchBuilder()
+ .setUrl(`${address.backend}/api/graph`)
+ .setIsGraphQLEndpoint(true);
+
+ useEffect(() => {
+ loadTemplates();
+ }, []);
+
+ const loadTemplates = async () => {
+ setIsLoading(true);
+ const query = `
+ query GetEmailTemplates {
+ templates: getEmailTemplates {
+ templateId
+ title
+ content {
+ content {
+ blockType
+ settings
+ }
+ style
+ meta
+ }
+ }
+ }`;
+
+ const fetcher = fetch
+ .setPayload({
+ query,
+ })
+ .build();
+
+ try {
+ dispatch && dispatch(networkAction(true));
+ const response = await fetcher.exec();
+ if (response.templates) {
+ setTemplates(response.templates);
+ }
+ } catch (e: any) {
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description: e.message,
+ variant: "destructive",
+ });
+ } finally {
+ dispatch && dispatch(networkAction(false));
+ setIsLoading(false);
+ }
+ };
+
+ const createSequence = async (template: EmailTemplate) => {
+ const mutation = `
+ mutation createSequence(
+ $type: SequenceType!,
+ $title: String!,
+ $content: String!
+ ) {
+ sequence: createSequence(type: $type, title: $title, content: $content) {
+ sequenceId
+ }
+ }
+ `;
+ const fetch = new FetchBuilder()
+ .setUrl(`${address.backend}/api/graph`)
+ .setPayload({
+ query: mutation,
+ variables: {
+ type: type.toUpperCase(),
+ title: template.title,
+ content: JSON.stringify(template.content),
+ },
+ })
+ .setIsGraphQLEndpoint(true)
+ .build();
+ try {
+ dispatch &&
+ (dispatch as ThunkDispatch)(
+ networkAction(true),
+ );
+ const response = await fetch.exec();
+ if (response.sequence && response.sequence.sequenceId) {
+ router.push(
+ `/dashboard/mails/${type}/${response.sequence.sequenceId}`,
+ );
+ }
+ } catch (err) {
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description: err.message,
+ variant: "destructive",
+ });
+ } finally {
+ dispatch &&
+ (dispatch as ThunkDispatch)(
+ networkAction(false),
+ );
+ }
+ };
+
+ const onTemplateClick = (template: EmailTemplate) => {
+ createSequence(template);
+ };
+
+ return (
+
+
Choose a template
+
+ {[...systemTemplates, ...templates].map((template) => (
+
onTemplateClick(template)}
+ >
+
+ {template.title}
+
+
+
+
+
+ ))}
+
+
+ );
+};
+
+export default NewMailPageClient;
diff --git a/apps/web/app/(with-contexts)/dashboard/mails/new/page.tsx b/apps/web/app/(with-contexts)/dashboard/mails/new/page.tsx
new file mode 100644
index 000000000..0d721bbed
--- /dev/null
+++ b/apps/web/app/(with-contexts)/dashboard/mails/new/page.tsx
@@ -0,0 +1,26 @@
+import { promises as fs } from "fs";
+import path from "path";
+import { EmailTemplate } from "@courselit/common-models";
+import NewMailPageClient from "./new-mail-page-client";
+
+async function getSystemTemplates(): Promise {
+ const templatesDir = path.join(
+ process.cwd(),
+ "apps/web/templates/system-emails",
+ );
+ const filenames = await fs.readdir(templatesDir);
+
+ const templates = filenames.map(async (filename) => {
+ const filePath = path.join(templatesDir, filename);
+ const fileContents = await fs.readFile(filePath, "utf8");
+ return JSON.parse(fileContents);
+ });
+
+ return Promise.all(templates);
+}
+
+export default async function NewMailPage() {
+ const systemTemplates = await getSystemTemplates();
+
+ return ;
+}
diff --git a/apps/web/app/(with-contexts)/dashboard/mails/template/[id]/internal/page.tsx b/apps/web/app/(with-contexts)/dashboard/mails/template/[id]/internal/page.tsx
new file mode 100644
index 000000000..408f832dd
--- /dev/null
+++ b/apps/web/app/(with-contexts)/dashboard/mails/template/[id]/internal/page.tsx
@@ -0,0 +1,188 @@
+"use client";
+
+import { EmailEditor } from "@courselit/email-editor";
+import "@courselit/email-editor/styles.css";
+import { TOAST_TITLE_ERROR } from "@ui-config/strings";
+import { useState, useEffect, useCallback, useRef, useMemo } from "react";
+import type { Email as EmailContent } from "@courselit/email-editor";
+import { useToast } from "@courselit/components-library";
+import { debounce } from "@courselit/utils";
+import { EmailEditorLayout } from "@components/admin/mails/editor-layout";
+import { useGraphQLFetch } from "@/hooks/use-graphql-fetch";
+import { EmailTemplate } from "@courselit/common-models";
+
+export default function EmailTemplateEditorPage({
+ params,
+}: {
+ params: {
+ id: string;
+ };
+}) {
+ const [email, setEmail] = useState(null);
+ const [isSaving, setIsSaving] = useState(false);
+ const { toast } = useToast();
+ const [template, setTemplate] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const initialValues = useRef({
+ content: null as EmailContent | null,
+ });
+ const isInitialLoad = useRef(true);
+
+ const fetch = useGraphQLFetch();
+
+ const loadTemplate = useCallback(async () => {
+ setLoading(true);
+ const query = `
+ query GetEmailTemplate($templateId: String!) {
+ template: getEmailTemplate(templateId: $templateId) {
+ templateId
+ title
+ content {
+ content {
+ blockType
+ settings
+ }
+ style
+ meta
+ }
+ }
+ }
+ `;
+ try {
+ const response = await fetch
+ .setPayload({
+ query,
+ variables: {
+ templateId: params.id,
+ },
+ })
+ .build()
+ .exec();
+ if (response.template) {
+ setTemplate(response.template);
+ initialValues.current = {
+ content: response.template.content,
+ };
+ setEmail(response.template.content);
+ isInitialLoad.current = false;
+ }
+ } catch (e: any) {
+ setError(e.message);
+ } finally {
+ setLoading(false);
+ }
+ }, [params.id, fetch]);
+
+ useEffect(() => {
+ loadTemplate();
+ }, [loadTemplate]);
+
+ const saveEmail = useCallback(
+ async (emailContent: EmailContent) => {
+ const hasChanged =
+ JSON.stringify(emailContent) !==
+ JSON.stringify(initialValues.current.content);
+
+ if (!hasChanged) {
+ return;
+ }
+
+ setIsSaving(true);
+
+ const mutation = `
+ mutation UpdateEmailTemplate(
+ $templateId: String!,
+ $content: String,
+ ) {
+ template: updateEmailTemplate(
+ templateId: $templateId,
+ content: $content,
+ ) {
+ templateId
+ title
+ content {
+ content {
+ blockType
+ settings
+ }
+ style
+ meta
+ }
+ }
+ }`;
+
+ const fetcher = fetch
+ .setPayload({
+ query: mutation,
+ variables: {
+ templateId: params.id,
+ content: JSON.stringify(emailContent),
+ },
+ })
+ .build();
+
+ try {
+ await fetcher.exec();
+
+ initialValues.current = {
+ content: emailContent,
+ };
+ } catch (e: any) {
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description: e.message,
+ variant: "destructive",
+ });
+ } finally {
+ setIsSaving(false);
+ }
+ },
+ [params.id, fetch, toast],
+ );
+
+ const debouncedSave = useMemo(() => debounce(saveEmail, 1000), [saveEmail]);
+
+ const handleEmailChange = (newEmailContent: EmailContent) => {
+ debouncedSave(newEmailContent);
+ };
+
+ const title = template?.title || "Untitled Template";
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {email && (
+
+ )}
+
+ );
+}
+
+const LoadingState = () => (
+
+
Loading template editor...
+
+);
+
+const ErrorState = ({ error }: { error: string }) => (
+
+
Failed to load template: {error}
+
+);
diff --git a/apps/web/app/(with-contexts)/dashboard/mails/template/[id]/page.tsx b/apps/web/app/(with-contexts)/dashboard/mails/template/[id]/page.tsx
new file mode 100644
index 000000000..0b59fd5a3
--- /dev/null
+++ b/apps/web/app/(with-contexts)/dashboard/mails/template/[id]/page.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { useSearchParams } from "next/navigation";
+import { Button2 } from "@courselit/components-library";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { LogOut } from "lucide-react";
+import Link from "next/link";
+
+export default function EmailTemplateEditorPage({
+ params,
+}: {
+ params: {
+ id: string;
+ };
+}) {
+ const searchParams = useSearchParams();
+ const redirectTo = searchParams?.get("redirectTo");
+
+ return (
+
+ );
+}
+const EditorLayout = ({
+ src,
+ redirectTo,
+}: {
+ src: string;
+ redirectTo: string;
+}) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Exit
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/app/(with-contexts)/layout.tsx b/apps/web/app/(with-contexts)/layout.tsx
index 0affcbba3..351248b63 100644
--- a/apps/web/app/(with-contexts)/layout.tsx
+++ b/apps/web/app/(with-contexts)/layout.tsx
@@ -19,6 +19,7 @@ export default async function Layout({
const config: ServerConfig = {
turnstileSiteKey: process.env.TURNSTILE_SITE_KEY || "",
queueServer: process.env.QUEUE_SERVER || "",
+ recaptchaSiteKey: process.env.RECAPTCHA_SITE_KEY || "",
};
return (
diff --git a/apps/web/app/api/config/route.ts b/apps/web/app/api/config/route.ts
index 1f6f0ab7f..38ec7c716 100644
--- a/apps/web/app/api/config/route.ts
+++ b/apps/web/app/api/config/route.ts
@@ -4,7 +4,10 @@ export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
return Response.json(
- { turnstileSiteKey: process.env.TURNSTILE_SITE_KEY },
+ {
+ turnstileSiteKey: process.env.TURNSTILE_SITE_KEY,
+ recaptchaSiteKey: process.env.RECAPTCHA_SITE_KEY || "",
+ },
{ status: 200 },
);
}
diff --git a/apps/web/app/api/recaptcha/route.ts b/apps/web/app/api/recaptcha/route.ts
new file mode 100644
index 000000000..0b8943535
--- /dev/null
+++ b/apps/web/app/api/recaptcha/route.ts
@@ -0,0 +1,70 @@
+import { NextRequest, NextResponse } from "next/server";
+
+export async function POST(request: NextRequest) {
+ const secretKey = process.env.RECAPTCHA_SECRET_KEY;
+ if (!secretKey) {
+ console.error("reCAPTCHA secret key not found.");
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 },
+ );
+ }
+
+ let requestBody;
+ try {
+ requestBody = await request.json();
+ } catch (error) {
+ return NextResponse.json(
+ { error: "Invalid request body" },
+ { status: 400 },
+ );
+ }
+
+ const { token } = requestBody;
+
+ if (!token) {
+ return NextResponse.json(
+ { error: "reCAPTCHA token not found" },
+ { status: 400 },
+ );
+ }
+
+ const formData = `secret=${secretKey}&response=${token}`;
+
+ try {
+ const response = await fetch(
+ "https://www.google.com/recaptcha/api/siteverify",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: formData,
+ },
+ );
+
+ if (!response.ok) {
+ console.error("Failed to verify reCAPTCHA token with Google");
+ return NextResponse.json(
+ { error: "Failed to verify reCAPTCHA token" },
+ { status: 500 },
+ );
+ }
+
+ const googleResponse = await response.json();
+ return NextResponse.json({
+ success: googleResponse.success,
+ score: googleResponse.score,
+ action: googleResponse.action,
+ challenge_ts: googleResponse.challenge_ts,
+ hostname: googleResponse.hostname,
+ "error-codes": googleResponse["error-codes"],
+ });
+ } catch (error) {
+ console.error("Error verifying reCAPTCHA token:", error);
+ return NextResponse.json(
+ { error: "Error verifying reCAPTCHA token" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/apps/web/components/admin/mails/index.tsx b/apps/web/components/admin/mails/index.tsx
index 7dd332c6d..40f089ce8 100644
--- a/apps/web/components/admin/mails/index.tsx
+++ b/apps/web/components/admin/mails/index.tsx
@@ -15,7 +15,9 @@ import {
PAGE_HEADER_ALL_MAILS,
BROADCASTS,
SEQUENCES,
+ TEMPLATES,
BTN_NEW_SEQUENCE,
+ BTN_NEW_TEMPLATE,
TOAST_TITLE_ERROR,
} from "../../../ui-config/strings";
import { FetchBuilder } from "@courselit/utils";
@@ -35,17 +37,18 @@ import {
import { AnyAction } from "redux";
import RequestForm from "./request-form";
import SequencesList from "./sequences-list";
+import TemplatesList from "./templates-list";
const { networkAction } = actionCreators;
import { Button } from "@components/ui/button";
interface MailsProps {
address: Address;
- selectedTab: typeof BROADCASTS | typeof SEQUENCES;
+ selectedTab: typeof BROADCASTS | typeof SEQUENCES | typeof TEMPLATES;
dispatch?: AppDispatch;
loading: boolean;
}
-type MailsTab = typeof BROADCASTS | typeof SEQUENCES;
+type MailsTab = typeof BROADCASTS | typeof SEQUENCES | typeof TEMPLATES;
export default function Mails({
address,
@@ -147,12 +150,56 @@ export default function Mails({
}
};
+ const createEmailTemplate = async (): Promise => {
+ const mutation = `
+ mutation createEmailTemplate($title: String!) {
+ template: createEmailTemplate(title: $title) {
+ templateId
+ }
+ }
+ `;
+ const fetch = new FetchBuilder()
+ .setUrl(`${address.backend}/api/graph`)
+ .setPayload({
+ query: mutation,
+ variables: {
+ title: "New template",
+ },
+ })
+ .setIsGraphQLEndpoint(true)
+ .build();
+ try {
+ dispatch &&
+ (dispatch as ThunkDispatch)(
+ networkAction(true),
+ );
+ const response = await fetch.exec();
+ if (response.template && response.template.templateId) {
+ router.push(
+ `/dashboard/mails/template/${response.template.templateId}`,
+ );
+ }
+ } catch (err) {
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description: err.message,
+ variant: "destructive",
+ });
+ } finally {
+ dispatch &&
+ (dispatch as ThunkDispatch)(
+ networkAction(false),
+ );
+ }
+ };
+
const onPrimaryButtonClick = (): void => {
if (selectedTab === BROADCASTS) {
- createSequence("broadcast");
+ router.push(`/dashboard/mails/new?type=broadcast`);
} else if (selectedTab === SEQUENCES) {
- createSequence("sequence");
+ router.push(`/dashboard/mails/new?type=sequence`);
} else {
+ createEmailTemplate();
}
};
@@ -222,12 +269,14 @@ export default function Mails({
{
router.replace(`/dashboard/mails?tab=${tab}`);
@@ -245,6 +294,11 @@ export default function Mails({
loading={loading}
dispatch={dispatch}
/>
+
);
diff --git a/apps/web/components/admin/mails/templates-list.tsx b/apps/web/components/admin/mails/templates-list.tsx
new file mode 100644
index 000000000..af88f66d3
--- /dev/null
+++ b/apps/web/components/admin/mails/templates-list.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import {
+ Address,
+ EmailTemplate,
+} from "@courselit/common-models";
+import { Link, useToast } from "@courselit/components-library";
+import { AppDispatch } from "@courselit/state-management";
+import { networkAction } from "@courselit/state-management/dist/action-creators";
+import { FetchBuilder } from "@courselit/utils";
+import {
+ TOAST_TITLE_ERROR,
+ MAIL_TABLE_HEADER_TITLE,
+} from "@ui-config/strings";
+import { useEffect, useState } from "react";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Skeleton } from "@/components/ui/skeleton";
+
+interface TemplatesListProps {
+ address: Address;
+ loading: boolean;
+ dispatch?: AppDispatch;
+}
+
+const TemplatesList = ({
+ address,
+ dispatch,
+ loading,
+}: TemplatesListProps) => {
+ const [templates, setTemplates] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const { toast } = useToast();
+
+ const fetch = new FetchBuilder()
+ .setUrl(`${address.backend}/api/graph`)
+ .setIsGraphQLEndpoint(true);
+
+ useEffect(() => {
+ loadTemplates();
+ }, []);
+
+ const loadTemplates = async () => {
+ setIsLoading(true);
+ const query = `
+ query GetEmailTemplates {
+ templates: getEmailTemplates {
+ templateId
+ title
+ }
+ }`;
+
+ const fetcher = fetch
+ .setPayload({
+ query,
+ })
+ .build();
+
+ try {
+ dispatch && dispatch(networkAction(true));
+ const response = await fetcher.exec();
+ if (response.templates) {
+ setTemplates(response.templates);
+ }
+ } catch (e: any) {
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description: e.message,
+ variant: "destructive",
+ });
+ } finally {
+ dispatch && dispatch(networkAction(false));
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+ {MAIL_TABLE_HEADER_TITLE}
+
+
+
+ {isLoading
+ ? Array.from({ length: 10 }).map((_, idx) => (
+
+
+
+
+
+ ))
+ : templates.map((template) => (
+
+
+
+ {template.title}
+
+
+
+ ))}
+
+
+
+ );
+};
+
+export default TemplatesList;
diff --git a/apps/web/components/recaptcha-script-loader.tsx b/apps/web/components/recaptcha-script-loader.tsx
new file mode 100644
index 000000000..9d812d9bc
--- /dev/null
+++ b/apps/web/components/recaptcha-script-loader.tsx
@@ -0,0 +1,24 @@
+"use client";
+
+import { useContext } from "react";
+import Script from "next/script";
+import { ServerConfigContext } from "@components/contexts";
+
+const RecaptchaScriptLoader = () => {
+ const { recaptchaSiteKey } = useContext(ServerConfigContext);
+
+ if (recaptchaSiteKey) {
+ return (
+
+ );
+ }
+
+ return null;
+};
+
+export default RecaptchaScriptLoader;
diff --git a/apps/web/graphql/mails/logic.ts b/apps/web/graphql/mails/logic.ts
index 72351d47c..af12ae297 100644
--- a/apps/web/graphql/mails/logic.ts
+++ b/apps/web/graphql/mails/logic.ts
@@ -32,6 +32,7 @@ import { defaultEmail } from "@courselit/email-editor";
import { User } from "@courselit/common-models";
import EmailDeliveryModel from "@models/EmailDelivery";
import EmailEventModel from "@models/EmailEvent";
+import EmailTemplate from "@models/EmailTemplate";
const { permissions } = constants;
@@ -93,6 +94,8 @@ const defaultEmailContent = {
export async function createSequence(
ctx: GQLContext,
type: (typeof Constants.mailTypes)[number],
+ title?: string,
+ content?: string,
): Promise<(Sequence & { creatorId: string }) | null> {
checkIfAuthenticated(ctx);
@@ -106,16 +109,19 @@ export async function createSequence(
domain: ctx.subdomain._id,
type,
status: Constants.sequenceStatus[0],
- title: internal.default_email_sequence_name,
+ title: title || internal.default_email_sequence_name,
creatorId: ctx.user.userId,
emails: [
{
emailId,
- content: defaultEmailContent,
+ content: content
+ ? JSON.parse(content)
+ : defaultEmailContent,
subject:
- type === "broadcast"
+ title ||
+ (type === "broadcast"
? internal.default_email_broadcast_subject
- : internal.default_email_sequence_subject,
+ : internal.default_email_sequence_subject),
delayInMillis: 0,
published: false,
},
@@ -1061,3 +1067,123 @@ export async function getSubscribersCount({
return count;
}
+
+export async function createEmailTemplate({
+ title,
+ context,
+}: {
+ title: string;
+ context: GQLContext;
+}) {
+ checkIfAuthenticated(context);
+
+ if (!checkPermission(context.user.permissions, [permissions.manageUsers])) {
+ throw new Error(responses.action_not_allowed);
+ }
+
+ const template = new EmailTemplate({
+ domain: context.subdomain._id,
+ title,
+ creatorId: context.user.userId,
+ content: defaultEmailContent,
+ });
+
+ await template.save();
+
+ return template;
+}
+
+export async function updateEmailTemplate({
+ templateId,
+ title,
+ content,
+ context,
+}: {
+ templateId: string;
+ title?: string;
+ content?: string;
+ context: GQLContext;
+}) {
+ checkIfAuthenticated(context);
+
+ if (!checkPermission(context.user.permissions, [permissions.manageUsers])) {
+ throw new Error(responses.action_not_allowed);
+ }
+
+ const template = await EmailTemplate.findOne({
+ templateId,
+ domain: context.subdomain._id,
+ });
+
+ if (!template) {
+ throw new Error(responses.item_not_found);
+ }
+
+ if (title) {
+ template.title = title;
+ }
+
+ if (content) {
+ template.content = JSON.parse(content);
+ }
+
+ await template.save();
+
+ return template;
+}
+
+export async function deleteEmailTemplate({
+ templateId,
+ context,
+}: {
+ templateId: string;
+ context: GQLContext;
+}) {
+ checkIfAuthenticated(context);
+
+ if (!checkPermission(context.user.permissions, [permissions.manageUsers])) {
+ throw new Error(responses.action_not_allowed);
+ }
+
+ await EmailTemplate.deleteOne({
+ templateId,
+ domain: context.subdomain._id,
+ });
+
+ return true;
+}
+
+export async function getEmailTemplate({
+ templateId,
+ context,
+}: {
+ templateId: string;
+ context: GQLContext;
+}) {
+ checkIfAuthenticated(context);
+
+ if (!checkPermission(context.user.permissions, [permissions.manageUsers])) {
+ throw new Error(responses.action_not_allowed);
+ }
+
+ const template = await EmailTemplate.findOne({
+ templateId,
+ domain: context.subdomain._id,
+ });
+
+ return template;
+}
+
+export async function getEmailTemplates({ context }: { context: GQLContext }) {
+ checkIfAuthenticated(context);
+
+ if (!checkPermission(context.user.permissions, [permissions.manageUsers])) {
+ throw new Error(responses.action_not_allowed);
+ }
+
+ const templates = await EmailTemplate.find({
+ domain: context.subdomain._id,
+ });
+
+ return templates;
+}
diff --git a/apps/web/graphql/mails/mutation.ts b/apps/web/graphql/mails/mutation.ts
index 85884e670..080d65c7b 100644
--- a/apps/web/graphql/mails/mutation.ts
+++ b/apps/web/graphql/mails/mutation.ts
@@ -23,6 +23,9 @@ import {
pauseSequence,
updateMailRequest,
deleteMailFromSequence,
+ createEmailTemplate,
+ updateEmailTemplate,
+ deleteEmailTemplate,
} from "./logic";
import types from "./types";
import { Constants, Event } from "@courselit/common-models";
@@ -44,12 +47,22 @@ const mutations = {
type: types.sequence,
args: {
type: { type: new GraphQLNonNull(types.sequenceType) },
+ title: { type: GraphQLString },
+ content: { type: GraphQLString },
},
resolve: async (
_: any,
- { type }: { type: (typeof Constants.mailTypes)[number] },
+ {
+ type,
+ title,
+ content,
+ }: {
+ type: (typeof Constants.mailTypes)[number];
+ title?: string;
+ content?: string;
+ },
context: GQLContext,
- ) => createSequence(context, type),
+ ) => createSequence(context, type, title, content),
},
addMailToSequence: {
type: types.sequence,
@@ -319,5 +332,57 @@ const mutations = {
context: GQLContext,
) => updateMailRequest(context, reason),
},
+
+ createEmailTemplate: {
+ type: types.emailTemplate,
+ args: {
+ title: { type: new GraphQLNonNull(GraphQLString) },
+ },
+ resolve: async (
+ _: any,
+ { title }: { title: string },
+ context: GQLContext,
+ ) => createEmailTemplate({ title, context }),
+ },
+
+ updateEmailTemplate: {
+ type: types.emailTemplate,
+ args: {
+ templateId: { type: new GraphQLNonNull(GraphQLString) },
+ title: { type: GraphQLString },
+ content: { type: GraphQLString },
+ },
+ resolve: async (
+ _: any,
+ {
+ templateId,
+ title,
+ content,
+ }: {
+ templateId: string;
+ title?: string;
+ content?: string;
+ },
+ context: GQLContext,
+ ) =>
+ updateEmailTemplate({
+ templateId,
+ title,
+ content,
+ context,
+ }),
+ },
+
+ deleteEmailTemplate: {
+ type: GraphQLBoolean,
+ args: {
+ templateId: { type: new GraphQLNonNull(GraphQLString) },
+ },
+ resolve: async (
+ _: any,
+ { templateId }: { templateId: string },
+ context: GQLContext,
+ ) => deleteEmailTemplate({ templateId, context }),
+ },
};
export default mutations;
diff --git a/apps/web/graphql/mails/query.ts b/apps/web/graphql/mails/query.ts
index be67492de..a70a67b44 100644
--- a/apps/web/graphql/mails/query.ts
+++ b/apps/web/graphql/mails/query.ts
@@ -20,6 +20,8 @@ import {
getEmailSentCount,
getSubscribers,
getSubscribersCount,
+ getEmailTemplate,
+ getEmailTemplates,
} from "./logic";
import SearchData from "./models/search-data";
import GQLContext from "../../models/GQLContext";
@@ -185,6 +187,21 @@ const queries = {
context: GQLContext,
) => getSubscribersCount({ ctx: context, sequenceId }),
},
+
+ getEmailTemplate: {
+ type: types.emailTemplate,
+ args: {
+ templateId: { type: new GraphQLNonNull(GraphQLString) },
+ },
+ resolve: (_: any, { templateId }: { templateId: string }, context: GQLContext) =>
+ getEmailTemplate({ templateId, context }),
+ },
+
+ getEmailTemplates: {
+ type: new GraphQLList(types.emailTemplate),
+ resolve: (_: any, {}: {}, context: GQLContext) =>
+ getEmailTemplates({ context }),
+ },
};
export default queries;
diff --git a/apps/web/graphql/mails/types.ts b/apps/web/graphql/mails/types.ts
index 8a14ad349..65cb01489 100644
--- a/apps/web/graphql/mails/types.ts
+++ b/apps/web/graphql/mails/types.ts
@@ -214,6 +214,15 @@ const mailRequestStatus = new GraphQLObjectType({
},
});
+const emailTemplate = new GraphQLObjectType({
+ name: "EmailTemplate",
+ fields: {
+ templateId: { type: new GraphQLNonNull(GraphQLString) },
+ title: { type: new GraphQLNonNull(GraphQLString) },
+ content: { type: new GraphQLNonNull(sequenceEmailContent) },
+ },
+});
+
const types = {
mail,
mailUpdate,
@@ -227,5 +236,6 @@ const types = {
sequenceEmailActionType,
sequenceEmailContent,
mailRequestStatus,
+ emailTemplate,
};
export default types;
diff --git a/apps/web/hooks/use-recaptcha.ts b/apps/web/hooks/use-recaptcha.ts
new file mode 100644
index 000000000..996085d5d
--- /dev/null
+++ b/apps/web/hooks/use-recaptcha.ts
@@ -0,0 +1,56 @@
+import { useCallback, useContext } from "react";
+import { ServerConfigContext } from "@components/contexts";
+
+/**
+ * Custom hook for Google reCAPTCHA v3.
+ * It uses ServerConfigContext to get the reCAPTCHA site key.
+ *
+ * @returns {object} An object containing the `executeRecaptcha` function.
+ */
+export const useRecaptcha = () => {
+ const serverConfig = useContext(ServerConfigContext);
+ const recaptchaSiteKey = serverConfig?.recaptchaSiteKey;
+
+ const executeRecaptcha = useCallback(
+ async (action: string): Promise => {
+ if (!recaptchaSiteKey) {
+ console.error(
+ "reCAPTCHA site key not found in ServerConfigContext.",
+ );
+ return null;
+ }
+
+ if (
+ typeof window !== "undefined" &&
+ window.grecaptcha &&
+ window.grecaptcha.ready
+ ) {
+ return new Promise((resolve) => {
+ window.grecaptcha.ready(async () => {
+ if (!recaptchaSiteKey) {
+ // Double check, though already checked above
+ console.error(
+ "reCAPTCHA site key became unavailable before execution.",
+ );
+ resolve(null);
+ return;
+ }
+ const token = await window.grecaptcha.execute(
+ recaptchaSiteKey,
+ { action },
+ );
+ resolve(token);
+ });
+ });
+ } else {
+ console.error(
+ "reCAPTCHA (window.grecaptcha) not available. Ensure the script is loaded.",
+ );
+ return null;
+ }
+ },
+ [recaptchaSiteKey], // Dependency array includes recaptchaSiteKey
+ );
+
+ return { executeRecaptcha };
+};
diff --git a/apps/web/package.json b/apps/web/package.json
index 2d9b5a950..752b66840 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,6 +1,6 @@
{
"name": "@courselit/web",
- "version": "0.61.6",
+ "version": "0.61.7",
"private": true,
"scripts": {
"dev": "next dev",
diff --git a/apps/web/templates/system-emails/plain-text.json b/apps/web/templates/system-emails/plain-text.json
new file mode 100644
index 000000000..507ff124a
--- /dev/null
+++ b/apps/web/templates/system-emails/plain-text.json
@@ -0,0 +1,19 @@
+{
+ "templateId": "system-2",
+ "title": "Plain text",
+ "content": {
+ "style": {
+ "backgroundColor": "#ffffff",
+ "width": 600
+ },
+ "content": [
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "Write your email here."
+ }
+ }
+ ],
+ "meta": {}
+ }
+}
diff --git a/apps/web/templates/system-emails/simple-announcement.json b/apps/web/templates/system-emails/simple-announcement.json
new file mode 100644
index 000000000..5919938b5
--- /dev/null
+++ b/apps/web/templates/system-emails/simple-announcement.json
@@ -0,0 +1,19 @@
+{
+ "templateId": "system-1",
+ "title": "Simple Announcement",
+ "content": {
+ "style": {
+ "backgroundColor": "#ffffff",
+ "width": 600
+ },
+ "content": [
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "# Announce something!"
+ }
+ }
+ ],
+ "meta": {}
+ }
+}
diff --git a/apps/web/ui-config/strings.ts b/apps/web/ui-config/strings.ts
index fe6f8cd4a..e30424282 100644
--- a/apps/web/ui-config/strings.ts
+++ b/apps/web/ui-config/strings.ts
@@ -215,6 +215,7 @@ export const SITE_APIKEYS_SETTING_HEADER = "API Keys";
export const SITE_MAILS_HEADER = "Mails";
export const BROADCASTS = "Broadcasts";
export const SEQUENCES = "Sequences";
+export const TEMPLATES = "Templates";
export const SITE_MAILING_ADDRESS_SETTING_HEADER = "Mailing Address";
export const SITE_MAILING_ADDRESS_SETTING_EXPLANATION =
"This is required in order to comply with the CAN-SPAM Act.";
@@ -537,7 +538,9 @@ export const TOAST_MAIL_SENT = "Mail scheduled to be sent";
export const PAGE_PLACEHOLDER_MAIL = "Your mails will show up here";
export const BTN_NEW_MAIL = "New broadcast";
export const BTN_NEW_SEQUENCE = "New sequence";
+export const BTN_NEW_TEMPLATE = "New template";
export const MAIL_TABLE_HEADER_SUBJECT = "Subject";
+export const MAIL_TABLE_HEADER_TITLE = "Title";
export const MAIL_TABLE_HEADER_RECEPIENTS = "No. of recipients";
export const MAIL_SENDER_YOU = "You";
export const MAIL_TABLE_HEADER_SENDER = "Sender";
diff --git a/deployment/docker/docker-compose.yml b/deployment/docker/docker-compose.yml
index 4a7b976e4..40f44cfc7 100644
--- a/deployment/docker/docker-compose.yml
+++ b/deployment/docker/docker-compose.yml
@@ -57,7 +57,11 @@ services:
# checking the logs for the API key.
# - MEDIALIT_APIKEY=${MEDIALIT_APIKEY}
# - MEDIALIT_SERVER=http://medialit
-
+ #
+ # Google reCAPTCHA v3 is used to prevent abuse of the login functionality.
+ # Uncomment the following lines to use reCAPTCHA.
+ # - RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY}
+ # - RECAPTCHA_SECRET_KEY=${RECAPTCHA_SECRET_KEY}
expose:
- "${PORT:-80}"
diff --git a/packages/common-models/src/server-config.ts b/packages/common-models/src/server-config.ts
index 723df3c59..174ba441b 100644
--- a/packages/common-models/src/server-config.ts
+++ b/packages/common-models/src/server-config.ts
@@ -1,4 +1,5 @@
export interface ServerConfig {
turnstileSiteKey: string;
queueServer: string;
+ recaptchaSiteKey?: string;
}