From f5022a810c8d58c1aa573c83f95d334940b15c92 Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Sun, 16 Nov 2025 05:37:47 +0000
Subject: [PATCH 1/6] 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.
---
.husky/pre-commit | 2 +-
apps/docs/package.json | 2 +-
.../(with-layout)/login/login-form.tsx | 98 +++++++--
.../mails/new/new-mail-page-client.tsx | 171 ++++++++++++++++
.../dashboard/mails/new/page.tsx | 26 +++
.../mails/template/[id]/internal/page.tsx | 188 ++++++++++++++++++
.../dashboard/mails/template/[id]/page.tsx | 72 +++++++
apps/web/app/(with-contexts)/layout.tsx | 1 +
apps/web/app/api/config/route.ts | 5 +-
apps/web/app/api/recaptcha/route.ts | 70 +++++++
apps/web/components/admin/mails/index.tsx | 66 +++++-
.../components/admin/mails/templates-list.tsx | 118 +++++++++++
.../components/recaptcha-script-loader.tsx | 24 +++
apps/web/graphql/mails/logic.ts | 134 ++++++++++++-
apps/web/graphql/mails/mutation.ts | 69 ++++++-
apps/web/graphql/mails/query.ts | 17 ++
apps/web/graphql/mails/types.ts | 10 +
apps/web/hooks/use-recaptcha.ts | 56 ++++++
apps/web/package.json | 2 +-
.../templates/system-emails/plain-text.json | 19 ++
.../system-emails/simple-announcement.json | 19 ++
apps/web/ui-config/strings.ts | 3 +
deployment/docker/docker-compose.yml | 6 +-
packages/common-models/src/server-config.ts | 1 +
24 files changed, 1148 insertions(+), 31 deletions(-)
create mode 100644 apps/web/app/(with-contexts)/dashboard/mails/new/new-mail-page-client.tsx
create mode 100644 apps/web/app/(with-contexts)/dashboard/mails/new/page.tsx
create mode 100644 apps/web/app/(with-contexts)/dashboard/mails/template/[id]/internal/page.tsx
create mode 100644 apps/web/app/(with-contexts)/dashboard/mails/template/[id]/page.tsx
create mode 100644 apps/web/app/api/recaptcha/route.ts
create mode 100644 apps/web/components/admin/mails/templates-list.tsx
create mode 100644 apps/web/components/recaptcha-script-loader.tsx
create mode 100644 apps/web/hooks/use-recaptcha.ts
create mode 100644 apps/web/templates/system-emails/plain-text.json
create mode 100644 apps/web/templates/system-emails/simple-announcement.json
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;
}
From 4d44d5682a4f02cd3fff814d371760fdd0bfbb9d Mon Sep 17 00:00:00 2001
From: Rajat
Date: Sat, 28 Mar 2026 17:28:10 +0530
Subject: [PATCH 2/6] Lint fixes
---
.../mails/new/new-mail-page-client.tsx | 19 +-
.../components/admin/mails/templates-list.tsx | 16 +-
apps/web/package.json | 268 +++++++++---------
3 files changed, 141 insertions(+), 162 deletions(-)
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
index ffda0f4d7..91b398a46 100644
--- 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
@@ -1,24 +1,13 @@
"use client";
-import {
- Address,
- EmailTemplate,
- SequenceType,
-} from "@courselit/common-models";
+import { EmailTemplate, SequenceType } from "@courselit/common-models";
import { useToast } from "@courselit/components-library";
-import { AppDispatch, AppState } from "@courselit/state-management";
+import { 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 { TOAST_TITLE_ERROR } from "@ui-config/strings";
import { useEffect, useState } from "react";
-import {
- Card,
- CardContent,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useRouter, useSearchParams } from "next/navigation";
import { ThunkDispatch } from "redux-thunk";
import { AnyAction } from "redux";
diff --git a/apps/web/components/admin/mails/templates-list.tsx b/apps/web/components/admin/mails/templates-list.tsx
index af88f66d3..c69d03415 100644
--- a/apps/web/components/admin/mails/templates-list.tsx
+++ b/apps/web/components/admin/mails/templates-list.tsx
@@ -1,17 +1,11 @@
"use client";
-import {
- Address,
- EmailTemplate,
-} from "@courselit/common-models";
+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 { TOAST_TITLE_ERROR, MAIL_TABLE_HEADER_TITLE } from "@ui-config/strings";
import { useEffect, useState } from "react";
import {
Table,
@@ -29,11 +23,7 @@ interface TemplatesListProps {
dispatch?: AppDispatch;
}
-const TemplatesList = ({
- address,
- dispatch,
- loading,
-}: TemplatesListProps) => {
+const TemplatesList = ({ address, dispatch, loading }: TemplatesListProps) => {
const [templates, setTemplates] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
diff --git a/apps/web/package.json b/apps/web/package.json
index 60f8f9662..f95001a1e 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,137 +1,137 @@
{
- "name": "@courselit/web",
- "version": "0.73.9",
- "private": true,
- "scripts": {
- "dev": "next dev",
- "build": "next build",
- "start": "next start",
- "prettier": "prettier --write **/*.ts"
- },
- "browserslist": {
- "production": [
- "chrome >= 109",
- "edge >= 109",
- "firefox >= 109",
- "safari >= 15.4",
- "ios_saf >= 15.4",
- "not dead"
- ],
- "development": [
- "last 1 chrome version",
- "last 1 firefox version",
- "last 1 safari version"
- ]
- },
- "dependencies": {
- "@better-auth/sso": "^1.4.6",
- "@courselit/common-logic": "workspace:^",
- "@courselit/common-models": "workspace:^",
- "@courselit/components-library": "workspace:^",
- "@courselit/email-editor": "workspace:^",
- "@courselit/icons": "workspace:^",
- "@courselit/orm-models": "workspace:^",
- "@courselit/page-blocks": "workspace:^",
- "@courselit/page-models": "workspace:^",
- "@courselit/page-primitives": "workspace:^",
- "@courselit/text-editor": "workspace:^",
- "@courselit/utils": "workspace:^",
- "@dnd-kit/core": "^6.3.1",
- "@dnd-kit/sortable": "^8.0.0",
- "@dnd-kit/utilities": "^3.2.2",
- "@hookform/resolvers": "^3.9.1",
- "@radix-ui/react-alert-dialog": "^1.1.11",
- "@radix-ui/react-avatar": "^1.1.3",
- "@radix-ui/react-checkbox": "^1.1.4",
- "@radix-ui/react-collapsible": "^1.1.3",
- "@radix-ui/react-compose-refs": "^1.1.1",
- "@radix-ui/react-dialog": "^1.1.6",
- "@radix-ui/react-dropdown-menu": "^2.1.6",
- "@radix-ui/react-label": "^2.1.4",
- "@radix-ui/react-popover": "^1.1.6",
- "@radix-ui/react-progress": "^1.1.7",
- "@radix-ui/react-radio-group": "^1.2.3",
- "@radix-ui/react-scroll-area": "^1.2.3",
- "@radix-ui/react-select": "^2.1.6",
- "@radix-ui/react-separator": "^1.1.4",
- "@radix-ui/react-slot": "^1.2.3",
- "@radix-ui/react-switch": "^1.1.3",
- "@radix-ui/react-tabs": "^1.1.3",
- "@radix-ui/react-toast": "^1.2.6",
- "@radix-ui/react-toggle": "^1.1.6",
- "@radix-ui/react-toggle-group": "^1.1.7",
- "@radix-ui/react-tooltip": "^1.1.8",
- "@radix-ui/react-visually-hidden": "^1.1.0",
- "@stripe/stripe-js": "^5.4.0",
- "@types/base-64": "^1.0.0",
- "adm-zip": "^0.5.16",
- "archiver": "^5.3.1",
- "aws4": "^1.13.2",
- "base-64": "^1.0.0",
- "better-auth": "^1.4.1",
- "chart.js": "^4.4.7",
- "class-variance-authority": "^0.7.0",
- "clsx": "^2.1.1",
- "color-convert": "^3.1.0",
- "cookie": "^0.4.2",
- "date-fns": "^4.1.0",
- "graphql": "^16.10.0",
- "graphql-type-json": "^0.3.2",
- "jsdom": "^26.1.0",
- "lodash.debounce": "^4.0.8",
- "lucide-react": "^0.553.0",
- "medialit": "0.2.0",
- "mongodb": "^6.15.0",
- "mongoose": "^8.13.1",
- "next": "^16.0.10",
- "next-themes": "^0.4.6",
- "nodemailer": "^6.7.2",
- "pug": "^3.0.2",
- "razorpay": "^2.9.4",
- "react": "19.2.0",
- "react-chartjs-2": "^5.3.0",
- "react-csv": "^2.2.2",
- "react-dom": "19.2.0",
- "react-hook-form": "^7.54.1",
- "recharts": "^2.15.1",
- "remirror": "^3.0.1",
- "sharp": "^0.33.2",
- "slugify": "^1.6.5",
- "sonner": "^2.0.7",
- "stripe": "^17.5.0",
- "tailwind-merge": "^2.5.4",
- "tailwindcss-animate": "^1.0.7",
- "xml2js": "^0.6.2",
- "zod": "^3.24.1"
- },
- "devDependencies": {
- "@eslint/eslintrc": "^3.3.1",
- "@shelf/jest-mongodb": "^5.2.2",
- "@types/adm-zip": "^0.5.7",
- "@types/bcryptjs": "^2.4.2",
- "@types/cookie": "^0.4.1",
- "@types/mongodb": "^4.0.7",
- "@types/node": "17.0.21",
- "@types/nodemailer": "^6.4.4",
- "@types/pug": "^2.0.6",
- "@types/react": "19.2.4",
- "@types/xml2js": "^0.4.14",
- "eslint": "^9.12.0",
- "eslint-config-next": "16.0.3",
- "eslint-config-prettier": "^9.0.0",
- "identity-obj-proxy": "^3.0.0",
- "mongodb-memory-server": "^10.1.4",
- "postcss": "^8.4.27",
- "prettier": "^3.0.2",
- "tailwind-config": "workspace:^",
- "tailwindcss": "^3.4.1",
- "ts-jest": "^29.4.4",
- "tsconfig": "workspace:^",
- "typescript": "^5.6.2"
- },
- "pnpm": {
- "overrides": {
- "@types/react": "19.2.4"
+ "name": "@courselit/web",
+ "version": "0.73.9",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "prettier": "prettier --write **/*.ts"
+ },
+ "browserslist": {
+ "production": [
+ "chrome >= 109",
+ "edge >= 109",
+ "firefox >= 109",
+ "safari >= 15.4",
+ "ios_saf >= 15.4",
+ "not dead"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ },
+ "dependencies": {
+ "@better-auth/sso": "^1.4.6",
+ "@courselit/common-logic": "workspace:^",
+ "@courselit/common-models": "workspace:^",
+ "@courselit/components-library": "workspace:^",
+ "@courselit/email-editor": "workspace:^",
+ "@courselit/icons": "workspace:^",
+ "@courselit/orm-models": "workspace:^",
+ "@courselit/page-blocks": "workspace:^",
+ "@courselit/page-models": "workspace:^",
+ "@courselit/page-primitives": "workspace:^",
+ "@courselit/text-editor": "workspace:^",
+ "@courselit/utils": "workspace:^",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^8.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
+ "@hookform/resolvers": "^3.9.1",
+ "@radix-ui/react-alert-dialog": "^1.1.11",
+ "@radix-ui/react-avatar": "^1.1.3",
+ "@radix-ui/react-checkbox": "^1.1.4",
+ "@radix-ui/react-collapsible": "^1.1.3",
+ "@radix-ui/react-compose-refs": "^1.1.1",
+ "@radix-ui/react-dialog": "^1.1.6",
+ "@radix-ui/react-dropdown-menu": "^2.1.6",
+ "@radix-ui/react-label": "^2.1.4",
+ "@radix-ui/react-popover": "^1.1.6",
+ "@radix-ui/react-progress": "^1.1.7",
+ "@radix-ui/react-radio-group": "^1.2.3",
+ "@radix-ui/react-scroll-area": "^1.2.3",
+ "@radix-ui/react-select": "^2.1.6",
+ "@radix-ui/react-separator": "^1.1.4",
+ "@radix-ui/react-slot": "^1.2.3",
+ "@radix-ui/react-switch": "^1.1.3",
+ "@radix-ui/react-tabs": "^1.1.3",
+ "@radix-ui/react-toast": "^1.2.6",
+ "@radix-ui/react-toggle": "^1.1.6",
+ "@radix-ui/react-toggle-group": "^1.1.7",
+ "@radix-ui/react-tooltip": "^1.1.8",
+ "@radix-ui/react-visually-hidden": "^1.1.0",
+ "@stripe/stripe-js": "^5.4.0",
+ "@types/base-64": "^1.0.0",
+ "adm-zip": "^0.5.16",
+ "archiver": "^5.3.1",
+ "aws4": "^1.13.2",
+ "base-64": "^1.0.0",
+ "better-auth": "^1.4.1",
+ "chart.js": "^4.4.7",
+ "class-variance-authority": "^0.7.0",
+ "clsx": "^2.1.1",
+ "color-convert": "^3.1.0",
+ "cookie": "^0.4.2",
+ "date-fns": "^4.1.0",
+ "graphql": "^16.10.0",
+ "graphql-type-json": "^0.3.2",
+ "jsdom": "^26.1.0",
+ "lodash.debounce": "^4.0.8",
+ "lucide-react": "^0.553.0",
+ "medialit": "0.2.0",
+ "mongodb": "^6.15.0",
+ "mongoose": "^8.13.1",
+ "next": "^16.0.10",
+ "next-themes": "^0.4.6",
+ "nodemailer": "^6.7.2",
+ "pug": "^3.0.2",
+ "razorpay": "^2.9.4",
+ "react": "19.2.0",
+ "react-chartjs-2": "^5.3.0",
+ "react-csv": "^2.2.2",
+ "react-dom": "19.2.0",
+ "react-hook-form": "^7.54.1",
+ "recharts": "^2.15.1",
+ "remirror": "^3.0.1",
+ "sharp": "^0.33.2",
+ "slugify": "^1.6.5",
+ "sonner": "^2.0.7",
+ "stripe": "^17.5.0",
+ "tailwind-merge": "^2.5.4",
+ "tailwindcss-animate": "^1.0.7",
+ "xml2js": "^0.6.2",
+ "zod": "^3.24.1"
+ },
+ "devDependencies": {
+ "@eslint/eslintrc": "^3.3.1",
+ "@shelf/jest-mongodb": "^5.2.2",
+ "@types/adm-zip": "^0.5.7",
+ "@types/bcryptjs": "^2.4.2",
+ "@types/cookie": "^0.4.1",
+ "@types/mongodb": "^4.0.7",
+ "@types/node": "17.0.21",
+ "@types/nodemailer": "^6.4.4",
+ "@types/pug": "^2.0.6",
+ "@types/react": "19.2.4",
+ "@types/xml2js": "^0.4.14",
+ "eslint": "^9.12.0",
+ "eslint-config-next": "16.0.3",
+ "eslint-config-prettier": "^9.0.0",
+ "identity-obj-proxy": "^3.0.0",
+ "mongodb-memory-server": "^10.1.4",
+ "postcss": "^8.4.27",
+ "prettier": "^3.0.2",
+ "tailwind-config": "workspace:^",
+ "tailwindcss": "^3.4.1",
+ "ts-jest": "^29.4.4",
+ "tsconfig": "workspace:^",
+ "typescript": "^5.6.2"
+ },
+ "pnpm": {
+ "overrides": {
+ "@types/react": "19.2.4"
+ }
}
- }
}
From daaf72cd6fe351a6b05d237e27e508d1083e2b69 Mon Sep 17 00:00:00 2001
From: Rajat
Date: Wed, 1 Apr 2026 10:48:14 +0530
Subject: [PATCH 3/6] Email templating system
---
AGENTS.md | 1 +
apps/docs/src/pages/en/website/blocks.md | 40 +-
apps/docs/src/pages/en/website/themes.md | 36 +-
apps/web/__mocks__/nanoid.ts | 2 +-
.../dashboard/(sidebar)/mails/mail-hub.tsx | 2 +-
.../__tests__/new-mail-page-client.test.tsx | 273 +++++++++++++
.../mails/new/__tests__/page.test.tsx | 125 ++++++
.../mails/new/new-mail-page-client.tsx | 341 ++++++++++++++++
.../dashboard/(sidebar)/mails/new/page.tsx | 96 +++++
.../mails/new/template-email-preview.tsx | 250 ++++++++++++
.../(sidebar)/mails/sequence/[id]/page.tsx | 40 +-
.../(sidebar)/mails/template/[id]/page.tsx | 383 ++++++++++++++++++
.../(with-contexts)/dashboard/mail/layout.tsx | 2 +-
.../sequence/[sequenceId]/[mailId]/page.tsx | 3 +-
.../template/[id]/internal/page.tsx | 47 +--
.../{mails => mail}/template/[id]/page.tsx | 14 +-
.../mails/new/new-mail-page-client.tsx | 160 --------
.../dashboard/mails/new/page.tsx | 26 --
.../admin/dashboard-skeleton/app-sidebar.tsx | 22 +-
apps/web/components/admin/empty-state.tsx | 44 ++
.../components/admin/mails/email-viewer.tsx | 1 +
apps/web/components/admin/mails/index.tsx | 52 +--
.../components/admin/mails/templates-list.tsx | 85 ++--
apps/web/config/strings.ts | 1 +
.../web/graphql/mails/__tests__/logic.test.ts | 251 ++++++++++++
apps/web/graphql/mails/logic.ts | 226 +++++++++--
apps/web/graphql/mails/mutation.ts | 28 +-
apps/web/graphql/mails/query.ts | 8 +-
.../templates/system-emails/announcement.json | 207 ++++++++++
apps/web/templates/system-emails/blank.json | 104 +++++
.../system-emails/new-user-welcome.json | 183 +++++++++
.../templates/system-emails/newsletter.json | 208 ++++++++++
.../templates/system-emails/plain-text.json | 19 -
.../system-emails/simple-announcement.json | 19 -
.../system-emails/upsell-products.json | 198 +++++++++
apps/web/ui-config/strings.ts | 39 ++
36 files changed, 3081 insertions(+), 455 deletions(-)
create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/__tests__/new-mail-page-client.test.tsx
create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/__tests__/page.test.tsx
create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/new-mail-page-client.tsx
create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/page.tsx
create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/template-email-preview.tsx
create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/template/[id]/page.tsx
rename apps/web/app/(with-contexts)/dashboard/{mails => mail}/template/[id]/internal/page.tsx (85%)
rename apps/web/app/(with-contexts)/dashboard/{mails => mail}/template/[id]/page.tsx (86%)
delete mode 100644 apps/web/app/(with-contexts)/dashboard/mails/new/new-mail-page-client.tsx
delete mode 100644 apps/web/app/(with-contexts)/dashboard/mails/new/page.tsx
create mode 100644 apps/web/components/admin/empty-state.tsx
create mode 100644 apps/web/graphql/mails/__tests__/logic.test.ts
create mode 100644 apps/web/templates/system-emails/announcement.json
create mode 100644 apps/web/templates/system-emails/blank.json
create mode 100644 apps/web/templates/system-emails/new-user-welcome.json
create mode 100644 apps/web/templates/system-emails/newsletter.json
delete mode 100644 apps/web/templates/system-emails/plain-text.json
delete mode 100644 apps/web/templates/system-emails/simple-announcement.json
create mode 100644 apps/web/templates/system-emails/upsell-products.json
diff --git a/AGENTS.md b/AGENTS.md
index 8c682be75..9efaa591c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -6,6 +6,7 @@
- Command for running tests: `pnpm test`.
- The project uses shadcn for building UI so stick to its conventions and design.
- In `apps/web` workspace, create a string first in `apps/web/config/strings.ts` and then import it in the `.tsx` files, instead of using inline strings.
+- For admin/dashboard empty states in `apps/web`, prefer reusing `apps/web/components/admin/empty-state.tsx` instead of creating one-off placeholder UIs.
- When working with forms, always use refs to keep the current state of the form's data and use it to enable/disable the form submit button.
- Check the name field inside each package's package.json to confirm the right name—skip the top-level one.
- While working with forms, always use zod and react-hook-form to validate the form. Take reference implementation from `apps/web/components/admin/settings/sso/new.tsx`.
diff --git a/apps/docs/src/pages/en/website/blocks.md b/apps/docs/src/pages/en/website/blocks.md
index 2b68f48ae..579434db1 100644
--- a/apps/docs/src/pages/en/website/blocks.md
+++ b/apps/docs/src/pages/en/website/blocks.md
@@ -17,7 +17,7 @@ CourseLit offers a wide range of page blocks so that you can build all sorts of
### [Header](#header)
-Expand to see Header block details
+ Expand to see Header block details
> This is a [shared block](#shared-blocks). All published changes to this block impact all pages on your website.
@@ -35,13 +35,13 @@ You will also see the newly added link on the header itself.
3. Click on the pencil icon against the newly added link to edit it as shown above.
4. Change the label (displayed as text on the header block) and the URL (where the user should be taken upon clicking the label on the header) and click `Done` to save.
- 
-
+
+
### [Rich Text](#rich-text)
-Expand to see Rich Text block details
+ Expand to see Rich Text block details
The rich text block uses the same text editor available elsewhere on the platform. It supports all functionality that does not require a toolbar, as the toolbar is hidden in this block.
@@ -68,13 +68,13 @@ The rich text block uses the same text editor available elsewhere on the platfor
1. Select the text.
2. Click on the floating `link` icon to reveal a text input.
3. In the popup text input, enter the URL as shown below and press Enter.
- 
-
+
+
### [Hero](#hero)
-Expand to see Hero block details
+ Expand to see Hero block details
A hero section of a web page is the section that immediately appears on screen, just under the header. The hero block helps you put the information front and center.
@@ -93,14 +93,14 @@ Following is how it looks on a page.
3. In the button text field, add the text that will be visible on the button.
4. In the button action, enter the URL the user should be taken to upon clicking.
- a. If the URL is from your own school, use its relative form, i.e., `/courses`.
- b. If the URL is from some external website, use the absolute (complete) URL, i.e., `https://website.com/courses`.
-
+a. If the URL is from your own school, use its relative form, i.e., `/courses`.
+b. If the URL is from some external website, use the absolute (complete) URL, i.e., `https://website.com/courses`.
+
### [Grid](#grid)
-Expand to see Grid block details
+ Expand to see Grid block details
A grid block comes in handy when you want to show some sort of list, for example, features list or advantages, etc. The list gets displayed in the grid format as shown below.
@@ -138,14 +138,14 @@ A grid block comes in handy when you want to show some sort of list, for example
3. In the button text field, add the text that will be visible on the button.
4. In the button action, enter the URL the user should be taken to upon clicking.
- a. If the URL is from your own school, use its relative form, i.e., `/courses`.
- b. If the URL is from some external website, use the absolute (complete) URL, i.e., `https://website.com/courses`.
-
+a. If the URL is from your own school, use its relative form, i.e., `/courses`.
+b. If the URL is from some external website, use the absolute (complete) URL, i.e., `https://website.com/courses`.
+
### [Featured](#featured)
-Expand to see Featured block details
+ Expand to see Featured block details
If you want to show your other products on a page, the featured widget is the one to use.
@@ -166,7 +166,7 @@ Following is how it looks on a page.
### [Curriculum](#curriculum)
-Expand to see Curriculum block details
+ Expand to see Curriculum block details
> This block can only be added to the products' sales pages.
@@ -187,7 +187,7 @@ Your audience can directly click on the lessons to see them in the course viewer
### [Banner](#banner)
-Expand to see Banner block details
+ Expand to see Banner block details
The banner block is the default block that shows the basic information about the page, i.e., on a sales page it shows the product's details like its title, description, featured image, and pricing, and on the homepage it shows your school's details like its name and subtitle.
@@ -229,7 +229,7 @@ Now, whenever your users enter their emails and press submit, they will see the
### [Newsletter signup](#newsletter-signup)
-Expand to see Newsletter signup block details
+ Expand to see Newsletter signup block details
Having a mailing list to sell directly to is a dream of every business, big or small. That's why CourseLit offers a dedicated block that lets you capture emails. It is also a [shared block](/en/pages/blocks#shared-page-blocks).
@@ -253,7 +253,7 @@ Following is an animation that shows the entire flow.
### [Embed](#embed)
-Expand to see Embed block details
+ Expand to see Embed block details
Embedding content from other websites is a common requirement. CourseLit offers a dedicated block that lets you embed content from other websites.
@@ -299,7 +299,7 @@ Here is [Cal.com](https://cal.com/)'s embed looks on a page.
### [Footer](#footer)
-Expand to see Footer block details
+ Expand to see Footer block details
> This is a [shared block](#shared-blocks). All published changes to this block impact all pages on your website.
diff --git a/apps/docs/src/pages/en/website/themes.md b/apps/docs/src/pages/en/website/themes.md
index 0700d2e29..5c704e4f2 100644
--- a/apps/docs/src/pages/en/website/themes.md
+++ b/apps/docs/src/pages/en/website/themes.md
@@ -192,14 +192,14 @@ The typography editor lets you customize text styles across your website. These
- Header 3: Smaller titles for subsections
- Header 4: Small titles for minor sections
- Preheader: Introductory text that appears above headers
-
+
Subheaders
- Subheader 1: Primary subheaders for section introductions
- Subheader 2: Secondary subheaders for supporting text
-
+
Body Text
@@ -207,7 +207,7 @@ The typography editor lets you customize text styles across your website. These
- Text 1: Main body text for content
- Text 2: Secondary body text for supporting content
- Caption: Small text for image captions and footnotes
-
+
Interactive Elements
@@ -215,7 +215,7 @@ The typography editor lets you customize text styles across your website. These
- Link: Text for clickable links
- Button: Text for buttons and calls-to-action
- Input: Text for form fields and search boxes
-
+
For each text style, you can customize:
@@ -243,7 +243,7 @@ CourseLit provides a carefully curated selection of professional fonts, organize
- **Mulish**: A geometric sans-serif with a modern feel
- **Nunito**: A well-balanced font with rounded terminals
- **Work Sans**: A clean, modern font with a geometric feel
-
+
Serif Fonts
@@ -253,7 +253,7 @@ CourseLit provides a carefully curated selection of professional fonts, organize
- **Playfair Display**: An elegant serif font for headings
- **Roboto Slab**: A serif variant of Roboto
- **Source Serif 4**: A serif font designed for digital reading
-
+
Display Fonts
@@ -264,7 +264,7 @@ CourseLit provides a carefully curated selection of professional fonts, organize
- **Rubik**: A sans-serif with a geometric feel
- **Oswald**: A reworking of the classic style
- **Bebas Neue**: A display font with a strong personality
-
+
Modern Fonts
@@ -272,7 +272,7 @@ CourseLit provides a carefully curated selection of professional fonts, organize
- **Lato**: A sans-serif font with a warm feel
- **PT Sans**: A font designed for public use
- **Quicksand**: A display sans-serif with rounded terminals
-
+
Each font is optimized for web use and includes multiple weights for flexibility in design. All fonts support Latin characters and are carefully selected for their readability and professional appearance.
@@ -290,7 +290,7 @@ The interactives editor allows you to customize the appearance of interactive el
- Shadow effects: From None to 2X Large
- Custom styles: Add your own custom styles using [supported Tailwind classes](#supported-tailwind-classes)
- Disabled state: How the button looks when it can't be clicked
-
+
Link
@@ -300,7 +300,7 @@ The interactives editor allows you to customize the appearance of interactive el
- Text shadow: Add depth to your links
- Custom styles: Add your own custom styles using [supported Tailwind classes](#supported-tailwind-classes)
- Disabled state: How the link looks when it can't be clicked
-
+
Card
@@ -309,7 +309,7 @@ The interactives editor allows you to customize the appearance of interactive el
- Border style: Choose from various border styles
- Shadow effects: Add depth to your cards
- Custom styles: Add your own custom styles using [supported Tailwind classes](#supported-tailwind-classes)
-
+
Input
@@ -320,7 +320,7 @@ The interactives editor allows you to customize the appearance of interactive el
- Shadow effects: Add depth to your input fields
- Custom styles: Add your own custom styles using [supported Tailwind classes](#supported-tailwind-classes)
- Disabled state: How the input looks when it can't be used
-
+
### 4. Structure
@@ -332,14 +332,14 @@ The structure editor lets you customize the layout of your pages, like section p
Page
- Maximum width options: - 2XL (42rem): Compact layout - 3XL (48rem): Standard layout - 4XL (56rem): Wide layout - 5XL (64rem): Extra wide layout - 6XL (72rem): Full width layout
-
+
Section
- Horizontal padding: Space on the left and right sides (None to 9X Large)
- Vertical padding: Space on the top and bottom (None to 9X Large)
-
+
## Publishing Changes
@@ -387,7 +387,7 @@ When adding custom styles to interactive elements, you can use the following Tai
- `text-6xl`: 6X large text
- `text-7xl`: 7X large text
- `text-8xl`: 8X large text
-
+
Padding
@@ -399,7 +399,7 @@ When adding custom styles to interactive elements, you can use the following Tai
#### Horizontal Padding
- `px-4` to `px-20`: Horizontal padding from 1rem to 5rem
-
+
Colors
@@ -454,7 +454,7 @@ Variants available: `hover`, `disabled`, `dark`
- `ease-out`: Ease out
- `ease-in-out`: Ease in and out
- `ease-linear`: Linear
-
+
Transforms
@@ -481,7 +481,7 @@ Variants available: `hover`, `disabled`, `dark`
- `scale-110`: 110% scale
- `scale-125`: 125% scale
- `scale-150`: 150% scale
-
+
Shadows
diff --git a/apps/web/__mocks__/nanoid.ts b/apps/web/__mocks__/nanoid.ts
index f634c2e79..4e4d2a3a4 100644
--- a/apps/web/__mocks__/nanoid.ts
+++ b/apps/web/__mocks__/nanoid.ts
@@ -1 +1 @@
-export const nanoid = jest.fn();
+export const nanoid = jest.fn(() => "mock-nanoid-id");
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/mail-hub.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/mail-hub.tsx
index 9822704e0..952af3713 100644
--- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/mail-hub.tsx
+++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/mail-hub.tsx
@@ -28,7 +28,7 @@ export default function MailHub() {
breadcrumbs={breadcrumbs}
permissions={[permissions.manageUsers]}
>
-
+
);
}
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/__tests__/new-mail-page-client.test.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/__tests__/new-mail-page-client.test.tsx
new file mode 100644
index 000000000..b7b3a408a
--- /dev/null
+++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/__tests__/new-mail-page-client.test.tsx
@@ -0,0 +1,273 @@
+import React from "react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import NewMailPageClient from "../new-mail-page-client";
+import { AddressContext, SiteInfoContext } from "@components/contexts";
+import {
+ MAIL_TEMPLATE_CHOOSER_CUSTOM_SECTION,
+ MAIL_TEMPLATE_CHOOSER_SYSTEM_SECTION,
+ TEMPLATE_CHOOSER_CUSTOM_EMPTY_STATE_DESCRIPTION,
+ TEMPLATE_CHOOSER_CUSTOM_EMPTY_STATE_TITLE,
+} from "@ui-config/strings";
+
+const mockToast = jest.fn();
+const mockPush = jest.fn();
+const mockExec = jest.fn();
+const mockSearchParams = new URLSearchParams();
+
+jest.mock("next/navigation", () => ({
+ useRouter: () => ({
+ push: mockPush,
+ }),
+ useSearchParams: () => ({
+ get: (key: string) => mockSearchParams.get(key),
+ }),
+}));
+
+jest.mock("@courselit/components-library", () => ({
+ useToast: () => ({
+ toast: mockToast,
+ }),
+}));
+
+jest.mock("@courselit/utils", () => {
+ const actual = jest.requireActual("@courselit/utils");
+ return {
+ ...actual,
+ FetchBuilder: jest.fn().mockImplementation(() => ({
+ setUrl: jest.fn().mockReturnThis(),
+ setPayload: jest.fn().mockReturnThis(),
+ setIsGraphQLEndpoint: jest.fn().mockReturnThis(),
+ build: jest.fn().mockReturnThis(),
+ exec: mockExec,
+ })),
+ };
+});
+
+jest.mock("../template-email-preview", () => ({
+ __esModule: true,
+ default: ({ content }: { content: any }) => (
+
+ {content?.meta?.previewText || "email-preview"}
+
+ ),
+}));
+
+jest.mock("@components/admin/empty-state", () => ({
+ __esModule: true,
+ default: ({
+ title,
+ description,
+ }: {
+ title: string;
+ description: string;
+ }) => (
+
+
{title}
+
{description}
+
+ ),
+}));
+
+jest.mock(
+ "@/components/ui/button",
+ () => ({
+ Button: ({
+ children,
+ ...props
+ }: React.ButtonHTMLAttributes) => (
+
+ ),
+ }),
+ { virtual: true },
+);
+
+jest.mock(
+ "@/components/ui/card",
+ () => ({
+ Card: ({
+ children,
+ onClick,
+ }: {
+ children: React.ReactNode;
+ onClick?: () => void;
+ }) => (
+
+ ),
+ CardContent: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ CardHeader: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ CardTitle: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ }),
+ { virtual: true },
+);
+
+jest.mock(
+ "@/components/ui/skeleton",
+ () => ({
+ Skeleton: () => ,
+ }),
+ { virtual: true },
+);
+
+const systemTemplate = {
+ templateId: "system-1",
+ title: "Announcement",
+ content: {
+ content: [],
+ style: {},
+ meta: { previewText: "system-template-preview" },
+ },
+};
+
+const customTemplate = {
+ templateId: "custom-1",
+ title: "Custom template",
+ content: {
+ content: [],
+ style: {},
+ meta: { previewText: "custom-template-preview" },
+ },
+};
+
+function renderPage() {
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+describe("NewMailPageClient", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockExec.mockReset();
+ mockPush.mockReset();
+ mockToast.mockReset();
+ mockSearchParams.forEach((_, key) => mockSearchParams.delete(key));
+ });
+
+ it("loads and renders system and custom templates", async () => {
+ mockSearchParams.set("type", "sequence");
+ mockExec.mockResolvedValueOnce({
+ systemTemplates: [systemTemplate],
+ templates: [customTemplate],
+ });
+
+ renderPage();
+
+ await waitFor(() => {
+ expect(screen.getByText("Announcement")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("Custom template")).toBeInTheDocument();
+ expect(
+ screen.getByText(MAIL_TEMPLATE_CHOOSER_SYSTEM_SECTION),
+ ).toBeInTheDocument();
+ });
+
+ it("creates a sequence from the selected template", async () => {
+ mockSearchParams.set("type", "sequence");
+ mockExec
+ .mockResolvedValueOnce({
+ systemTemplates: [systemTemplate],
+ templates: [],
+ })
+ .mockResolvedValueOnce({
+ sequence: {
+ sequenceId: "sequence-1",
+ },
+ });
+
+ renderPage();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(TEMPLATE_CHOOSER_CUSTOM_EMPTY_STATE_TITLE),
+ ).toBeInTheDocument();
+ });
+
+ expect(
+ screen.getByText(TEMPLATE_CHOOSER_CUSTOM_EMPTY_STATE_DESCRIPTION),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(MAIL_TEMPLATE_CHOOSER_CUSTOM_SECTION),
+ ).toBeInTheDocument();
+
+ fireEvent.click(screen.getByText(systemTemplate.title));
+
+ await waitFor(() => {
+ expect(mockPush).toHaveBeenCalledWith(
+ "/dashboard/mails/sequence/sequence-1",
+ );
+ });
+ });
+
+ it("adds a selected template to an existing sequence", async () => {
+ mockSearchParams.set("type", "sequence");
+ mockSearchParams.set("mode", "add-to-sequence");
+ mockSearchParams.set("sequenceId", "sequence-123");
+ mockExec
+ .mockResolvedValueOnce({
+ systemTemplates: [systemTemplate],
+ templates: [],
+ })
+ .mockResolvedValueOnce({
+ sequence: {
+ sequenceId: "sequence-123",
+ },
+ });
+
+ renderPage();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(TEMPLATE_CHOOSER_CUSTOM_EMPTY_STATE_TITLE),
+ ).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByText(systemTemplate.title));
+
+ await waitFor(() => {
+ expect(mockPush).toHaveBeenCalledWith(
+ "/dashboard/mails/sequence/sequence-123",
+ );
+ });
+ });
+});
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/__tests__/page.test.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/__tests__/page.test.tsx
new file mode 100644
index 000000000..6a4679928
--- /dev/null
+++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/__tests__/page.test.tsx
@@ -0,0 +1,125 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import NewMailPage from "../page";
+import {
+ BUTTON_CANCEL_TEXT,
+ PAGE_HEADER_CHOOSE_TEMPLATE,
+ PAGE_HEADER_EDIT_SEQUENCE,
+ SEQUENCES,
+ TEMPLATES,
+} from "@ui-config/strings";
+
+const mockDashboardContent = jest.fn(
+ ({
+ children,
+ breadcrumbs,
+ }: {
+ children: React.ReactNode;
+ breadcrumbs?: { label: string; href: string }[];
+ }) => (
+
+
+ {JSON.stringify(breadcrumbs || [])}
+
+ {children}
+
+ ),
+);
+
+jest.mock("@components/admin/dashboard-content", () => ({
+ __esModule: true,
+ default: (props: {
+ children: React.ReactNode;
+ breadcrumbs?: { label: string; href: string }[];
+ }) => mockDashboardContent(props),
+}));
+
+jest.mock("next/link", () => ({
+ __esModule: true,
+ default: ({
+ children,
+ href,
+ }: {
+ children: React.ReactNode;
+ href: string;
+ }) => {children},
+}));
+
+jest.mock(
+ "@/components/ui/button",
+ () => ({
+ Button: ({
+ children,
+ ...props
+ }: React.ButtonHTMLAttributes) => (
+
+ ),
+ }),
+ { virtual: true },
+);
+
+jest.mock("../new-mail-page-client", () => ({
+ __esModule: true,
+ default: () => ,
+}));
+
+describe("NewMailPage", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("renders sequence breadcrumbs and cancel link for add-to-sequence flow", async () => {
+ const element = await NewMailPage({
+ searchParams: Promise.resolve({
+ type: "sequence",
+ mode: "add-to-sequence",
+ sequenceId: "sequence-123",
+ source: "sequences",
+ }),
+ });
+
+ render(element);
+
+ expect(
+ screen.getByRole("heading", { name: PAGE_HEADER_CHOOSE_TEMPLATE }),
+ ).toBeInTheDocument();
+ expect(screen.getByTestId("new-mail-page-client")).toBeInTheDocument();
+
+ expect(screen.getByTestId("breadcrumbs-json")).toHaveTextContent(
+ JSON.stringify([
+ { label: SEQUENCES, href: `/dashboard/mails?tab=${SEQUENCES}` },
+ {
+ label: PAGE_HEADER_EDIT_SEQUENCE,
+ href: "/dashboard/mails/sequence/sequence-123",
+ },
+ { label: PAGE_HEADER_CHOOSE_TEMPLATE, href: "#" },
+ ]),
+ );
+
+ expect(
+ screen.getByRole("link", { name: BUTTON_CANCEL_TEXT }),
+ ).toHaveAttribute("href", "/dashboard/mails/sequence/sequence-123");
+ });
+
+ it("renders template breadcrumbs and cancel link for template flow", async () => {
+ const element = await NewMailPage({
+ searchParams: Promise.resolve({
+ type: "template",
+ source: "templates",
+ }),
+ });
+
+ render(element);
+
+ expect(screen.getByTestId("breadcrumbs-json")).toHaveTextContent(
+ JSON.stringify([
+ { label: TEMPLATES, href: `/dashboard/mails?tab=${TEMPLATES}` },
+ { label: PAGE_HEADER_CHOOSE_TEMPLATE, href: "#" },
+ ]),
+ );
+
+ expect(
+ screen.getByRole("link", { name: BUTTON_CANCEL_TEXT }),
+ ).toHaveAttribute("href", `/dashboard/mails?tab=${TEMPLATES}`);
+ });
+});
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/new-mail-page-client.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/new-mail-page-client.tsx
new file mode 100644
index 000000000..f56dae128
--- /dev/null
+++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/new-mail-page-client.tsx
@@ -0,0 +1,341 @@
+"use client";
+
+import { EmailTemplate } from "@courselit/common-models";
+import { useToast } from "@courselit/components-library";
+import { FetchBuilder } from "@courselit/utils";
+import {
+ MAIL_TEMPLATE_CHOOSER_CUSTOM_DESCRIPTION,
+ TEMPLATE_CHOOSER_CUSTOM_EMPTY_STATE_DESCRIPTION,
+ TEMPLATE_CHOOSER_CUSTOM_EMPTY_STATE_TITLE,
+ MAIL_TEMPLATE_CHOOSER_CUSTOM_SECTION,
+ MAIL_TEMPLATE_CHOOSER_SYSTEM_DESCRIPTION,
+ MAIL_TEMPLATE_CHOOSER_SYSTEM_SECTION,
+ TOAST_TITLE_ERROR,
+} from "@ui-config/strings";
+import { useEffect, useState } from "react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useRouter, useSearchParams } from "next/navigation";
+import { AddressContext } from "@components/contexts";
+import { useContext } from "react";
+import AdminEmptyState from "@components/admin/empty-state";
+import TemplateEmailPreview from "./template-email-preview";
+
+const sortSystemTemplates = (templates: EmailTemplate[]) =>
+ [...templates].sort((a, b) => {
+ if (a.title === "Blank") {
+ return 1;
+ }
+
+ if (b.title === "Blank") {
+ return -1;
+ }
+
+ return a.title.localeCompare(b.title);
+ });
+
+const TemplateGrid = ({
+ templates,
+ onTemplateClick,
+}: {
+ templates: EmailTemplate[];
+ onTemplateClick: (template: EmailTemplate) => void;
+}) => (
+
+ {templates.map((template) => (
+ onTemplateClick(template)}
+ >
+
+ {template.title}
+
+
+
+
+
+ ))}
+
+);
+
+const NewMailPageClient = () => {
+ const address = useContext(AddressContext);
+ const [systemTemplates, setSystemTemplates] = useState([]);
+ const [templates, setTemplates] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const { toast } = useToast();
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ const type = searchParams?.get("type");
+ const mode = searchParams?.get("mode");
+ const sequenceId = searchParams?.get("sequenceId");
+ const brandedSystemTemplates = sortSystemTemplates(systemTemplates);
+
+ const fetch = new FetchBuilder()
+ .setUrl(`${address.backend}/api/graph`)
+ .setIsGraphQLEndpoint(true);
+
+ useEffect(() => {
+ loadTemplates();
+ }, []);
+
+ const loadTemplates = async () => {
+ setIsLoading(true);
+ const query = `
+ query GetMailTemplates {
+ systemTemplates: getSystemEmailTemplates {
+ templateId
+ title
+ content {
+ content {
+ blockType
+ settings
+ }
+ style
+ meta
+ }
+ }
+ templates: getEmailTemplates {
+ templateId
+ title
+ content {
+ content {
+ blockType
+ settings
+ }
+ style
+ meta
+ }
+ }
+ }`;
+
+ const fetcher = fetch
+ .setPayload({
+ query,
+ })
+ .build();
+
+ try {
+ const response = await fetcher.exec();
+ if (response.systemTemplates) {
+ setSystemTemplates(response.systemTemplates);
+ }
+ if (response.templates) {
+ setTemplates(response.templates);
+ }
+ } catch (e: any) {
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description: e.message,
+ variant: "destructive",
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const createSequence = async (template: EmailTemplate) => {
+ const mutation = `
+ mutation createSequence(
+ $type: SequenceType!,
+ $templateId: String!
+ ) {
+ sequence: createSequence(type: $type, templateId: $templateId) {
+ sequenceId
+ }
+ }
+ `;
+ const fetch = new FetchBuilder()
+ .setUrl(`${address.backend}/api/graph`)
+ .setPayload({
+ query: mutation,
+ variables: {
+ type: type?.toUpperCase(),
+ templateId: template.templateId,
+ },
+ })
+ .setIsGraphQLEndpoint(true)
+ .build();
+ try {
+ 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",
+ });
+ }
+ };
+
+ const addTemplateToSequence = async (template: EmailTemplate) => {
+ if (!sequenceId) {
+ return;
+ }
+
+ const mutation = `
+ mutation AddMailToSequence(
+ $sequenceId: String!,
+ $templateId: String!
+ ) {
+ sequence: addMailToSequence(
+ sequenceId: $sequenceId,
+ templateId: $templateId
+ ) {
+ sequenceId
+ }
+ }
+ `;
+
+ const fetch = new FetchBuilder()
+ .setUrl(`${address.backend}/api/graph`)
+ .setPayload({
+ query: mutation,
+ variables: {
+ sequenceId,
+ templateId: template.templateId,
+ },
+ })
+ .setIsGraphQLEndpoint(true)
+ .build();
+
+ try {
+ const response = await fetch.exec();
+ if (response.sequence?.sequenceId) {
+ router.push(
+ `/dashboard/mails/sequence/${response.sequence.sequenceId}`,
+ );
+ }
+ } catch (err: any) {
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description: err.message,
+ variant: "destructive",
+ });
+ }
+ };
+
+ const createTemplateFromSelection = async (template: EmailTemplate) => {
+ const mutation = `
+ mutation CreateEmailTemplate($templateId: String!) {
+ template: createEmailTemplate(templateId: $templateId) {
+ templateId
+ }
+ }
+ `;
+
+ const fetch = new FetchBuilder()
+ .setUrl(`${address.backend}/api/graph`)
+ .setPayload({
+ query: mutation,
+ variables: {
+ templateId: template.templateId,
+ },
+ })
+ .setIsGraphQLEndpoint(true)
+ .build();
+
+ try {
+ const response = await fetch.exec();
+ if (response.template?.templateId) {
+ router.push(
+ `/dashboard/mails/template/${response.template.templateId}`,
+ );
+ }
+ } catch (err: any) {
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description: err.message,
+ variant: "destructive",
+ });
+ }
+ };
+
+ const onTemplateClick = (template: EmailTemplate) => {
+ if (mode === "add-to-sequence" && sequenceId) {
+ addTemplateToSequence(template);
+ return;
+ }
+
+ if (type === "template") {
+ createTemplateFromSelection(template);
+ return;
+ }
+
+ createSequence(template);
+ };
+
+ const skeletonCards = Array.from({ length: 6 });
+
+ return (
+
+
+
+
+ {MAIL_TEMPLATE_CHOOSER_SYSTEM_SECTION}
+
+
+ {MAIL_TEMPLATE_CHOOSER_SYSTEM_DESCRIPTION}
+
+
+
+
+
+
+
+ {MAIL_TEMPLATE_CHOOSER_CUSTOM_SECTION}
+
+
+ {MAIL_TEMPLATE_CHOOSER_CUSTOM_DESCRIPTION}
+
+
+ {isLoading ? (
+
+ {skeletonCards.map((_, idx) => (
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ ) : templates.length ? (
+
+ ) : (
+
+ )}
+
+
+ );
+};
+
+export default NewMailPageClient;
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/page.tsx
new file mode 100644
index 000000000..5ee9fe51b
--- /dev/null
+++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/page.tsx
@@ -0,0 +1,96 @@
+import DashboardContent from "@components/admin/dashboard-content";
+import { Button } from "@/components/ui/button";
+import NewMailPageClient from "./new-mail-page-client";
+import Link from "next/link";
+import {
+ BROADCASTS,
+ BUTTON_CANCEL_TEXT,
+ PAGE_HEADER_CHOOSE_TEMPLATE,
+ PAGE_HEADER_EDIT_SEQUENCE,
+ SEQUENCES,
+ TEMPLATES,
+} from "@ui-config/strings";
+
+const MAIL_KIND_BROADCAST = "broadcast";
+const MAIL_KIND_SEQUENCE = "sequence";
+const MAIL_KIND_TEMPLATE = "template";
+const NEW_MAIL_MODE_ADD_TO_SEQUENCE = "add-to-sequence";
+const NEW_MAIL_SOURCE_BROADCASTS = "broadcasts";
+const NEW_MAIL_SOURCE_SEQUENCES = "sequences";
+const NEW_MAIL_SOURCE_TEMPLATES = "templates";
+
+type MailKind =
+ | typeof MAIL_KIND_BROADCAST
+ | typeof MAIL_KIND_SEQUENCE
+ | typeof MAIL_KIND_TEMPLATE;
+type NewMailMode = typeof NEW_MAIL_MODE_ADD_TO_SEQUENCE;
+type NewMailSource =
+ | typeof NEW_MAIL_SOURCE_BROADCASTS
+ | typeof NEW_MAIL_SOURCE_SEQUENCES
+ | typeof NEW_MAIL_SOURCE_TEMPLATES;
+
+export default async function NewMailPage({
+ searchParams,
+}: {
+ searchParams: Promise<{
+ type?: MailKind;
+ mode?: NewMailMode;
+ sequenceId?: string;
+ source?: NewMailSource;
+ }>;
+}) {
+ const { type, mode, sequenceId, source } = await searchParams;
+ const isAddingToSequence =
+ mode === NEW_MAIL_MODE_ADD_TO_SEQUENCE && !!sequenceId;
+
+ const breadcrumbs = [
+ {
+ label:
+ type === MAIL_KIND_TEMPLATE
+ ? TEMPLATES
+ : type === MAIL_KIND_SEQUENCE
+ ? SEQUENCES
+ : BROADCASTS,
+ href:
+ type === MAIL_KIND_TEMPLATE
+ ? `/dashboard/mails?tab=${TEMPLATES}`
+ : `/dashboard/mails?tab=${type === MAIL_KIND_SEQUENCE ? SEQUENCES : BROADCASTS}`,
+ },
+ ...(isAddingToSequence
+ ? [
+ {
+ label: PAGE_HEADER_EDIT_SEQUENCE,
+ href: `/dashboard/mails/sequence/${sequenceId}`,
+ },
+ ]
+ : []),
+ {
+ label: PAGE_HEADER_CHOOSE_TEMPLATE,
+ href: "#",
+ },
+ ];
+
+ const cancelHref = isAddingToSequence
+ ? `/dashboard/mails/sequence/${sequenceId}`
+ : source === NEW_MAIL_SOURCE_TEMPLATES
+ ? `/dashboard/mails?tab=${TEMPLATES}`
+ : source === NEW_MAIL_SOURCE_SEQUENCES || type === MAIL_KIND_SEQUENCE
+ ? `/dashboard/mails?tab=${SEQUENCES}`
+ : `/dashboard/mails?tab=${BROADCASTS}`;
+
+ return (
+
+
+
+
+ {PAGE_HEADER_CHOOSE_TEMPLATE}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/template-email-preview.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/template-email-preview.tsx
new file mode 100644
index 000000000..4759868ea
--- /dev/null
+++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/template-email-preview.tsx
@@ -0,0 +1,250 @@
+import { useEffect, useRef, useState, startTransition } from "react";
+import {
+ defaultEmail,
+ Email,
+ renderEmailToHtml,
+} from "@courselit/email-editor";
+import { cn } from "@/lib/shadcn-utils";
+import { Skeleton } from "@/components/ui/skeleton";
+
+export default function TemplateEmailPreview({
+ content,
+ className,
+ minHeight = "420px",
+}: {
+ content: Email | null;
+ className?: string;
+ minHeight?: string;
+}) {
+ const [renderedHTML, setRenderedHTML] = useState(null);
+ const [isLoading, setIsLoading] = useState(!!content);
+ const [error, setError] = useState(null);
+ const wrapperRef = useRef(null);
+ const [wrapperWidth, setWrapperWidth] = useState(0);
+
+ useEffect(() => {
+ if (content) {
+ const normalizedEmail = normalizeEmailForPreview(content);
+
+ startTransition(() => {
+ setRenderedHTML(null);
+ setIsLoading(true);
+ setError(null);
+ });
+
+ renderEmailToHtml({
+ email: normalizedEmail,
+ })
+ .then((html) => {
+ startTransition(() => {
+ setRenderedHTML(html);
+ setIsLoading(false);
+ });
+ })
+ .catch((err) => {
+ startTransition(() => {
+ setError(err.message || "Failed to render email");
+ setIsLoading(false);
+ });
+ });
+ } else {
+ startTransition(() => {
+ setRenderedHTML(null);
+ setIsLoading(false);
+ setError(null);
+ });
+ }
+ }, [content]);
+
+ useEffect(() => {
+ if (!wrapperRef.current) {
+ return;
+ }
+
+ setWrapperWidth(wrapperRef.current.clientWidth);
+
+ const observer = new ResizeObserver((entries) => {
+ const entry = entries[0];
+ if (entry) {
+ setWrapperWidth(entry.contentRect.width);
+ }
+ });
+
+ observer.observe(wrapperRef.current);
+
+ return () => observer.disconnect();
+ }, [renderedHTML]);
+
+ if (!content) {
+ return null;
+ }
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return Error: {error}
;
+ }
+
+ if (!renderedHTML) {
+ return (
+
+ );
+ }
+
+ const normalizedEmail = normalizeEmailForPreview(content);
+ const previewHeight = toPixels(minHeight);
+ const previewWidth = getPreviewWidth(normalizedEmail);
+ const scale =
+ wrapperWidth > 0 ? Math.min(wrapperWidth / previewWidth, 1) : 1;
+ const previewViewportHeight =
+ scale > 0 ? previewHeight / scale : previewHeight;
+
+ return (
+
+ );
+}
+
+function getPreviewWidth(email: Email): number {
+ const width =
+ email.style?.structure?.page?.width ||
+ defaultEmail.style.structure.page.width;
+ const parsedWidth = Number.parseInt(width || "600px", 10);
+
+ return Number.isFinite(parsedWidth) ? parsedWidth : 600;
+}
+
+function toPixels(value: string): number {
+ const parsed = Number.parseInt(value, 10);
+
+ return Number.isFinite(parsed) ? parsed : 420;
+}
+
+function normalizeEmailForPreview(
+ content: Email,
+ { responsivePageWidth = false }: { responsivePageWidth?: boolean } = {},
+): Email {
+ const legacyStyle = (content as any)?.style || {};
+ const defaultStyle = defaultEmail.style;
+
+ return {
+ ...defaultEmail,
+ ...content,
+ meta: {
+ ...defaultEmail.meta,
+ ...(content.meta || {}),
+ },
+ style: {
+ ...defaultStyle,
+ ...(content.style || {}),
+ colors: {
+ ...defaultStyle.colors,
+ ...(content.style?.colors || {}),
+ background:
+ content.style?.colors?.background ||
+ legacyStyle.backgroundColor ||
+ defaultStyle.colors.background,
+ },
+ typography: {
+ ...defaultStyle.typography,
+ ...(content.style?.typography || {}),
+ header: {
+ ...defaultStyle.typography.header,
+ ...(content.style?.typography?.header || {}),
+ },
+ text: {
+ ...defaultStyle.typography.text,
+ ...(content.style?.typography?.text || {}),
+ },
+ link: {
+ ...defaultStyle.typography.link,
+ ...(content.style?.typography?.link || {}),
+ },
+ },
+ interactives: {
+ ...defaultStyle.interactives,
+ ...(content.style?.interactives || {}),
+ button: {
+ ...defaultStyle.interactives.button,
+ ...(content.style?.interactives?.button || {}),
+ padding: {
+ ...defaultStyle.interactives.button.padding,
+ ...(content.style?.interactives?.button?.padding || {}),
+ },
+ border: {
+ ...defaultStyle.interactives.button.border,
+ ...(content.style?.interactives?.button?.border || {}),
+ },
+ },
+ link: {
+ ...defaultStyle.interactives.link,
+ ...(content.style?.interactives?.link || {}),
+ padding: {
+ ...defaultStyle.interactives.link.padding,
+ ...(content.style?.interactives?.link?.padding || {}),
+ },
+ },
+ },
+ structure: {
+ ...defaultStyle.structure,
+ ...(content.style?.structure || {}),
+ page: {
+ ...defaultStyle.structure.page,
+ ...(content.style?.structure?.page || {}),
+ background:
+ content.style?.structure?.page?.background ||
+ legacyStyle.backgroundColor ||
+ defaultStyle.structure.page.background,
+ width: responsivePageWidth
+ ? "100%"
+ : content.style?.structure?.page?.width ||
+ (typeof legacyStyle.width === "number"
+ ? `${legacyStyle.width}px`
+ : legacyStyle.width) ||
+ defaultStyle.structure.page.width,
+ marginY: responsivePageWidth
+ ? "0px"
+ : content.style?.structure?.page?.marginY ||
+ defaultStyle.structure.page.marginY,
+ },
+ section: {
+ ...defaultStyle.structure.section,
+ ...(content.style?.structure?.section || {}),
+ padding: {
+ ...defaultStyle.structure.section.padding,
+ ...(content.style?.structure?.section?.padding || {}),
+ },
+ },
+ },
+ },
+ content: content.content || defaultEmail.content,
+ };
+}
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/sequence/[id]/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/sequence/[id]/page.tsx
index d2883634c..51aa20dfa 100644
--- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/sequence/[id]/page.tsx
+++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/sequence/[id]/page.tsx
@@ -1,7 +1,6 @@
"use client";
import DashboardContent from "@components/admin/dashboard-content";
-import { AddressContext } from "@components/contexts";
import {
DELETE_EMAIL_DIALOG_HEADER,
PAGE_HEADER_EDIT_SEQUENCE,
@@ -10,7 +9,7 @@ import {
TOAST_TITLE_ERROR,
TOAST_TITLE_SUCCESS,
} from "@ui-config/strings";
-import { useContext, useState, useEffect, useCallback, use } from "react";
+import { useState, useEffect, useCallback, use } from "react";
import { Tabbs, useToast } from "@courselit/components-library";
import EmailAnalytics from "@components/admin/mails/email-analytics";
import { useSequence } from "@/hooks/use-sequence";
@@ -29,6 +28,7 @@ import {
Badge,
} from "@courselit/components-library";
import { Community, Course } from "@courselit/common-models";
+import { useRouter } from "next/navigation";
import {
COMPOSE_SEQUENCE_ENTRANCE_CONDITION,
COMPOSE_SEQUENCE_ENTRANCE_CONDITION_DATA,
@@ -55,7 +55,7 @@ export default function Page(props: {
}>;
}) {
const params = use(props.params);
- const address = useContext(AddressContext);
+ const router = useRouter();
const { id } = params;
const { sequence, loading, loadSequence } = useSequence();
const [activeTab, setActiveTab] = useState("Compose");
@@ -182,35 +182,11 @@ export default function Page(props: {
}
}, [triggerType]);
- const addMailToSequence = useCallback(async () => {
- const query = `
- mutation AddMailToSequence($sequenceId: String!) {
- sequence: addMailToSequence(sequenceId: $sequenceId) {
- sequenceId,
- }
- }`;
-
- const fetcher = fetch
- .setPayload({ query, variables: { sequenceId: id } })
- .build();
-
- try {
- const response = await fetcher.exec();
- if (response.sequence) {
- await loadSequence(id);
- toast({
- title: TOAST_TITLE_SUCCESS,
- description: "New email added to sequence",
- });
- }
- } catch (e: any) {
- toast({
- title: TOAST_TITLE_ERROR,
- description: e.message,
- variant: "destructive",
- });
- }
- }, [fetch, id, loadSequence, toast]);
+ const addMailToSequence = useCallback(() => {
+ router.push(
+ `/dashboard/mails/new?type=sequence&sequenceId=${id}&mode=add-to-sequence`,
+ );
+ }, [id, router]);
const updateSequence = useCallback(async () => {
const query = `
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/template/[id]/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/template/[id]/page.tsx
new file mode 100644
index 000000000..bd5b2f2d4
--- /dev/null
+++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/template/[id]/page.tsx
@@ -0,0 +1,383 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState, use } from "react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import * as z from "zod";
+import DashboardContent from "@components/admin/dashboard-content";
+import EmailViewer from "@components/admin/mails/email-viewer";
+import { useGraphQLFetch } from "@/hooks/use-graphql-fetch";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@components/ui/alert-dialog";
+import { Button } from "@components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Separator } from "@/components/ui/separator";
+import { useToast } from "@courselit/components-library";
+import { EmailTemplate } from "@courselit/common-models";
+import { useRouter } from "next/navigation";
+import { truncate } from "@courselit/utils";
+import { Loader2, Save, Trash2 } from "lucide-react";
+import {
+ BTN_DELETE_TEMPLATE,
+ BUTTON_SAVING,
+ BUTTON_SAVE,
+ DANGER_ZONE_HEADER,
+ DELETE_TEMPLATE_DIALOG_DESCRIPTION,
+ DELETE_TEMPLATE_DIALOG_HEADER,
+ PAGE_HEADER_MANAGE,
+ TEMPLATES,
+ TEMPLATE_MANAGE_DESCRIPTION,
+ TEMPLATE_NAME_LABEL,
+ TEMPLATE_NAME_PLACEHOLDER,
+ TEMPLATE_PREVIEW_DESCRIPTION,
+ TEMPLATE_PREVIEW_HEADER,
+ TOAST_TEMPLATE_DELETED,
+ TOAST_TEMPLATE_SAVED,
+ TOAST_TITLE_ERROR,
+ TOAST_TITLE_SUCCESS,
+} from "@ui-config/strings";
+
+const formSchema = z.object({
+ title: z.string().trim().min(1, "Template name is required"),
+});
+
+type FormData = z.infer;
+
+export default function Page(props: {
+ params: Promise<{
+ id: string;
+ }>;
+}) {
+ const { id } = use(props.params);
+ const router = useRouter();
+ const { toast } = useToast();
+ const fetch = useGraphQLFetch();
+ const [template, setTemplate] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isSaving, setIsSaving] = useState(false);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const initialValuesRef = useRef({ title: "" });
+ const currentValuesRef = useRef({ title: "" });
+
+ const form = useForm({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ title: "",
+ },
+ mode: "onChange",
+ });
+
+ const watchedTitle = form.watch("title");
+
+ useEffect(() => {
+ currentValuesRef.current = { title: watchedTitle || "" };
+ }, [watchedTitle]);
+
+ const canSubmit = useMemo(() => {
+ const currentTitle = (watchedTitle || "").trim();
+
+ return (
+ currentTitle.length > 0 &&
+ currentTitle !== initialValuesRef.current.title &&
+ form.formState.isValid &&
+ !isLoading &&
+ !isSaving
+ );
+ }, [form.formState.isValid, isLoading, isSaving, watchedTitle]);
+
+ const loadTemplate = useCallback(async () => {
+ setIsLoading(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: id,
+ },
+ })
+ .build()
+ .exec();
+
+ if (response.template) {
+ setTemplate(response.template);
+ initialValuesRef.current = {
+ title: response.template.title.trim(),
+ };
+ currentValuesRef.current = {
+ title: response.template.title,
+ };
+ form.reset({
+ title: response.template.title,
+ });
+ }
+ } catch (err: any) {
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description: err.message,
+ variant: "destructive",
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ }, [fetch, form, id, toast]);
+
+ useEffect(() => {
+ loadTemplate();
+ }, [loadTemplate]);
+
+ const onSubmit = async () => {
+ const title = currentValuesRef.current.title.trim();
+
+ if (!title || title === initialValuesRef.current.title) {
+ return;
+ }
+
+ const mutation = `
+ mutation UpdateEmailTemplate($templateId: String!, $title: String!) {
+ template: updateEmailTemplate(
+ templateId: $templateId
+ title: $title
+ ) {
+ templateId
+ title
+ content {
+ content {
+ blockType
+ settings
+ }
+ style
+ meta
+ }
+ }
+ }
+ `;
+
+ try {
+ setIsSaving(true);
+
+ const response = await fetch
+ .setPayload({
+ query: mutation,
+ variables: {
+ templateId: id,
+ title,
+ },
+ })
+ .build()
+ .exec();
+
+ if (response.template) {
+ setTemplate(response.template);
+ initialValuesRef.current = {
+ title: response.template.title.trim(),
+ };
+ currentValuesRef.current = { title: response.template.title };
+ form.reset({ title: response.template.title });
+ toast({
+ title: TOAST_TITLE_SUCCESS,
+ description: TOAST_TEMPLATE_SAVED,
+ });
+ }
+ } catch (err: any) {
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description: err.message,
+ variant: "destructive",
+ });
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ const deleteTemplate = async () => {
+ const mutation = `
+ mutation DeleteEmailTemplate($templateId: String!) {
+ deleted: deleteEmailTemplate(templateId: $templateId)
+ }
+ `;
+
+ try {
+ setIsDeleting(true);
+
+ const response = await fetch
+ .setPayload({
+ query: mutation,
+ variables: {
+ templateId: id,
+ },
+ })
+ .build()
+ .exec();
+
+ if (response.deleted) {
+ toast({
+ title: TOAST_TITLE_SUCCESS,
+ description: TOAST_TEMPLATE_DELETED,
+ });
+ router.replace("/dashboard/mails?tab=Templates");
+ }
+ } catch (err: any) {
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description: err.message,
+ variant: "destructive",
+ });
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ const breadcrumbs = [
+ { label: TEMPLATES, href: "/dashboard/mails?tab=Templates" },
+ {
+ label: truncate(template?.title || PAGE_HEADER_MANAGE, 20),
+ href: "#",
+ },
+ ];
+
+ return (
+
+
+
+
+ {PAGE_HEADER_MANAGE}
+
+
+ {TEMPLATE_MANAGE_DESCRIPTION}
+
+
+
+
+
+
+
+
+
+ {TEMPLATE_PREVIEW_DESCRIPTION}
+
+
+
+ {!isLoading && (
+
+ )}
+
+
+
+ {DANGER_ZONE_HEADER}
+
+
+
+
+
+
+
+
+ {DELETE_TEMPLATE_DIALOG_HEADER}
+
+
+ {DELETE_TEMPLATE_DIALOG_DESCRIPTION}
+
+
+
+ Cancel
+
+ {BTN_DELETE_TEMPLATE}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(with-contexts)/dashboard/mail/layout.tsx b/apps/web/app/(with-contexts)/dashboard/mail/layout.tsx
index 0062940d7..c7fe17283 100644
--- a/apps/web/app/(with-contexts)/dashboard/mail/layout.tsx
+++ b/apps/web/app/(with-contexts)/dashboard/mail/layout.tsx
@@ -10,7 +10,7 @@ export async function generateMetadata(
};
}
-export default function DripEmailEditorLayout({
+export default function EmailLayout({
children,
}: {
children: React.ReactNode;
diff --git a/apps/web/app/(with-contexts)/dashboard/mail/sequence/[sequenceId]/[mailId]/page.tsx b/apps/web/app/(with-contexts)/dashboard/mail/sequence/[sequenceId]/[mailId]/page.tsx
index 279ae8a63..1b9d046a6 100644
--- a/apps/web/app/(with-contexts)/dashboard/mail/sequence/[sequenceId]/[mailId]/page.tsx
+++ b/apps/web/app/(with-contexts)/dashboard/mail/sequence/[sequenceId]/[mailId]/page.tsx
@@ -11,6 +11,7 @@ import {
} from "@/components/ui/tooltip";
import { LogOut } from "lucide-react";
import Link from "next/link";
+import { EMAIL_EDITOR_EMAIL_EDIT_HEADER } from "@ui-config/strings";
export default function EmailEditorPage(props: {
params: Promise<{
@@ -43,7 +44,7 @@ const EditorLayout = ({
- Email Editor
+ {EMAIL_EDITOR_EMAIL_EDIT_HEADER}
diff --git a/apps/web/app/(with-contexts)/dashboard/mails/template/[id]/internal/page.tsx b/apps/web/app/(with-contexts)/dashboard/mail/template/[id]/internal/page.tsx
similarity index 85%
rename from apps/web/app/(with-contexts)/dashboard/mails/template/[id]/internal/page.tsx
rename to apps/web/app/(with-contexts)/dashboard/mail/template/[id]/internal/page.tsx
index 408f832dd..37f8a497d 100644
--- a/apps/web/app/(with-contexts)/dashboard/mails/template/[id]/internal/page.tsx
+++ b/apps/web/app/(with-contexts)/dashboard/mail/template/[id]/internal/page.tsx
@@ -3,7 +3,7 @@
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 { useState, useEffect, useCallback, useRef, useMemo, use } from "react";
import type { Email as EmailContent } from "@courselit/email-editor";
import { useToast } from "@courselit/components-library";
import { debounce } from "@courselit/utils";
@@ -14,10 +14,11 @@ import { EmailTemplate } from "@courselit/common-models";
export default function EmailTemplateEditorPage({
params,
}: {
- params: {
+ params: Promise<{
id: string;
- };
+ }>;
}) {
+ const { id } = use(params);
const [email, setEmail] = useState(null);
const [isSaving, setIsSaving] = useState(false);
const { toast } = useToast();
@@ -55,7 +56,7 @@ export default function EmailTemplateEditorPage({
.setPayload({
query,
variables: {
- templateId: params.id,
+ templateId: id,
},
})
.build()
@@ -73,7 +74,7 @@ export default function EmailTemplateEditorPage({
} finally {
setLoading(false);
}
- }, [params.id, fetch]);
+ }, [id, fetch]);
useEffect(() => {
loadTemplate();
@@ -92,32 +93,32 @@ export default function EmailTemplateEditorPage({
setIsSaving(true);
const mutation = `
- mutation UpdateEmailTemplate(
- $templateId: String!,
- $content: String,
- ) {
- template: updateEmailTemplate(
- templateId: $templateId,
- content: $content,
+ mutation UpdateEmailTemplate(
+ $templateId: String!,
+ $content: String,
) {
- templateId
- title
- content {
+ template: updateEmailTemplate(
+ templateId: $templateId,
+ content: $content,
+ ) {
+ templateId
+ title
content {
- blockType
- settings
+ content {
+ blockType
+ settings
+ }
+ style
+ meta
}
- style
- meta
}
- }
- }`;
+ }`;
const fetcher = fetch
.setPayload({
query: mutation,
variables: {
- templateId: params.id,
+ templateId: id,
content: JSON.stringify(emailContent),
},
})
@@ -139,7 +140,7 @@ export default function EmailTemplateEditorPage({
setIsSaving(false);
}
},
- [params.id, fetch, toast],
+ [id, fetch, toast],
);
const debouncedSave = useMemo(() => debounce(saveEmail, 1000), [saveEmail]);
diff --git a/apps/web/app/(with-contexts)/dashboard/mails/template/[id]/page.tsx b/apps/web/app/(with-contexts)/dashboard/mail/template/[id]/page.tsx
similarity index 86%
rename from apps/web/app/(with-contexts)/dashboard/mails/template/[id]/page.tsx
rename to apps/web/app/(with-contexts)/dashboard/mail/template/[id]/page.tsx
index 0b59fd5a3..107d46db1 100644
--- a/apps/web/app/(with-contexts)/dashboard/mails/template/[id]/page.tsx
+++ b/apps/web/app/(with-contexts)/dashboard/mail/template/[id]/page.tsx
@@ -2,6 +2,7 @@
import { useSearchParams } from "next/navigation";
import { Button2 } from "@courselit/components-library";
+import { use } from "react";
import {
Tooltip,
TooltipContent,
@@ -10,24 +11,27 @@ import {
} from "@/components/ui/tooltip";
import { LogOut } from "lucide-react";
import Link from "next/link";
+import { EMAIL_EDITOR_TEMPLATE_EDIT_HEADER } from "@ui-config/strings";
export default function EmailTemplateEditorPage({
params,
}: {
- params: {
+ params: Promise<{
id: string;
- };
+ }>;
}) {
+ const { id } = use(params);
const searchParams = useSearchParams();
const redirectTo = searchParams?.get("redirectTo");
return (
);
}
+
const EditorLayout = ({
src,
redirectTo,
@@ -42,7 +46,7 @@ const EditorLayout = ({
- Template Editor
+ {EMAIL_EDITOR_TEMPLATE_EDIT_HEADER}
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
deleted file mode 100644
index 91b398a46..000000000
--- a/apps/web/app/(with-contexts)/dashboard/mails/new/new-mail-page-client.tsx
+++ /dev/null
@@ -1,160 +0,0 @@
-"use client";
-
-import { EmailTemplate, SequenceType } from "@courselit/common-models";
-import { useToast } from "@courselit/components-library";
-import { 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
deleted file mode 100644
index 0d721bbed..000000000
--- a/apps/web/app/(with-contexts)/dashboard/mails/new/page.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-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/components/admin/dashboard-skeleton/app-sidebar.tsx b/apps/web/components/admin/dashboard-skeleton/app-sidebar.tsx
index 3cfe17a2c..d76b162d2 100644
--- a/apps/web/components/admin/dashboard-skeleton/app-sidebar.tsx
+++ b/apps/web/components/admin/dashboard-skeleton/app-sidebar.tsx
@@ -32,8 +32,10 @@ import { ProfileContext, SiteInfoContext } from "@components/contexts";
import { checkPermission } from "@courselit/utils";
import { Profile, UIConstants } from "@courselit/common-models";
import {
+ BROADCASTS,
GET_SET_UP,
MY_CONTENT_HEADER,
+ SEQUENCES,
SIDEBAR_MENU_BLOGS,
SIDEBAR_MENU_MAILS,
SIDEBAR_MENU_PAGES,
@@ -44,6 +46,7 @@ import {
SITE_SETTINGS_SECTION_GENERAL,
SITE_SETTINGS_SECTION_MAILS,
SITE_SETTINGS_SECTION_PAYMENT,
+ TEMPLATES,
} from "@ui-config/strings";
import { NavSecondary } from "./nav-secondary";
import { usePathname, useSearchParams } from "next/navigation";
@@ -227,18 +230,25 @@ function getSidebarItems({
path?.startsWith("/dashboard/mail"),
items: [
{
- title: "Broadcasts",
- url: "/dashboard/mails?tab=Broadcasts",
+ title: BROADCASTS,
+ url: `/dashboard/mails?tab=${BROADCASTS}`,
isActive:
`${path}?tab=${tab}` ===
- "/dashboard/mails?tab=Broadcasts",
+ `/dashboard/mails?tab=${BROADCASTS}`,
},
{
- title: "Sequences",
- url: "/dashboard/mails?tab=Sequences",
+ title: SEQUENCES,
+ url: `/dashboard/mails?tab=${SEQUENCES}`,
isActive:
`${path}?tab=${tab}` ===
- "/dashboard/mails?tab=Sequences",
+ `/dashboard/mails?tab=${SEQUENCES}`,
+ },
+ {
+ title: TEMPLATES,
+ url: `/dashboard/mails?tab=${TEMPLATES}`,
+ isActive:
+ `${path}?tab=${tab}` ===
+ `/dashboard/mails?tab=${TEMPLATES}`,
},
],
});
diff --git a/apps/web/components/admin/empty-state.tsx b/apps/web/components/admin/empty-state.tsx
new file mode 100644
index 000000000..0ac4b79f8
--- /dev/null
+++ b/apps/web/components/admin/empty-state.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import Link from "next/link";
+import { FileText } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/shadcn-utils";
+
+interface AdminEmptyStateProps {
+ title: string;
+ description: string;
+ actionLabel?: string;
+ actionHref?: string;
+ className?: string;
+}
+
+export default function AdminEmptyState({
+ title,
+ description,
+ actionLabel,
+ actionHref,
+ className,
+}: AdminEmptyStateProps) {
+ return (
+
+
+
+
+
+
{title}
+
{description}
+ {actionLabel && actionHref ? (
+
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/web/components/admin/mails/email-viewer.tsx b/apps/web/components/admin/mails/email-viewer.tsx
index 80c85b950..97a4f3b2e 100644
--- a/apps/web/components/admin/mails/email-viewer.tsx
+++ b/apps/web/components/admin/mails/email-viewer.tsx
@@ -17,6 +17,7 @@ export default function EmailViewer({
useEffect(() => {
if (content) {
startTransition(() => {
+ setRenderedHTML(null);
setIsLoading(true);
setError(null);
});
diff --git a/apps/web/components/admin/mails/index.tsx b/apps/web/components/admin/mails/index.tsx
index e65cd0cbf..3b0e20a14 100644
--- a/apps/web/components/admin/mails/index.tsx
+++ b/apps/web/components/admin/mails/index.tsx
@@ -86,47 +86,15 @@ export default function Mails({ address, selectedTab }: MailsProps) {
getSiteInfo();
}, []);
- 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 {
- 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",
- });
- }
- };
-
const onPrimaryButtonClick = (): void => {
if (selectedTab === BROADCASTS) {
- router.push(`/dashboard/mails/new?type=broadcast`);
+ router.push(
+ `/dashboard/mails/new?type=broadcast&source=broadcasts`,
+ );
} else if (selectedTab === SEQUENCES) {
- router.push(`/dashboard/mails/new?type=sequence`);
+ router.push(`/dashboard/mails/new?type=sequence&source=sequences`);
} else {
- createEmailTemplate();
+ router.push(`/dashboard/mails/new?type=template&source=templates`);
}
};
@@ -206,14 +174,8 @@ export default function Mails({ address, selectedTab }: MailsProps) {
router.replace(`/dashboard/mails?tab=${tab}`);
}}
>
-
-
+
+
diff --git a/apps/web/components/admin/mails/templates-list.tsx b/apps/web/components/admin/mails/templates-list.tsx
index c69d03415..5bb028ecd 100644
--- a/apps/web/components/admin/mails/templates-list.tsx
+++ b/apps/web/components/admin/mails/templates-list.tsx
@@ -2,10 +2,14 @@
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 {
+ TOAST_TITLE_ERROR,
+ MAIL_TABLE_HEADER_TITLE,
+ TEMPLATES_EMPTY_STATE_CTA,
+ TEMPLATES_EMPTY_STATE_DESCRIPTION,
+ TEMPLATES_EMPTY_STATE_TITLE,
+} from "@ui-config/strings";
import { useEffect, useState } from "react";
import {
Table,
@@ -16,14 +20,13 @@ import {
TableRow,
} from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
+import AdminEmptyState from "@components/admin/empty-state";
interface TemplatesListProps {
address: Address;
- loading: boolean;
- dispatch?: AppDispatch;
}
-const TemplatesList = ({ address, dispatch, loading }: TemplatesListProps) => {
+const TemplatesList = ({ address }: TemplatesListProps) => {
const [templates, setTemplates] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
@@ -53,7 +56,6 @@ const TemplatesList = ({ address, dispatch, loading }: TemplatesListProps) => {
.build();
try {
- dispatch && dispatch(networkAction(true));
const response = await fetcher.exec();
if (response.templates) {
setTemplates(response.templates);
@@ -65,42 +67,51 @@ const TemplatesList = ({ address, dispatch, loading }: TemplatesListProps) => {
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}
-
-
-
- ))}
-
-
+ {!isLoading && templates.length === 0 ? (
+
+ ) : (
+
+
+
+ {MAIL_TABLE_HEADER_TITLE}
+
+
+
+ {isLoading
+ ? Array.from({ length: 10 }).map((_, idx) => (
+
+
+
+
+
+ ))
+ : templates.map((template) => (
+
+
+
+ {template.title}
+
+
+
+ ))}
+
+
+ )}
);
};
diff --git a/apps/web/config/strings.ts b/apps/web/config/strings.ts
index 12be83b4b..156c751ef 100644
--- a/apps/web/config/strings.ts
+++ b/apps/web/config/strings.ts
@@ -98,6 +98,7 @@ export const responses = {
"Email delivery failed for all recipients",
courses_cannot_be_downloaded: "A course cannot be offered as a download.",
apikey_already_exists: "Apikey with that name already exists",
+ email_template_already_exists: "A template with that name already exists",
sequence_details_missing: "The following settings are missing",
invalid_emails_order: "Invalid emails order",
no_published_emails: "No published emails",
diff --git a/apps/web/graphql/mails/__tests__/logic.test.ts b/apps/web/graphql/mails/__tests__/logic.test.ts
new file mode 100644
index 000000000..f9bb4fd47
--- /dev/null
+++ b/apps/web/graphql/mails/__tests__/logic.test.ts
@@ -0,0 +1,251 @@
+/**
+ * @jest-environment node
+ */
+
+import DomainModel from "@/models/Domain";
+import EmailTemplateModel from "@/models/EmailTemplate";
+import constants from "@/config/constants";
+import { responses } from "@/config/strings";
+import GQLContext from "@/models/GQLContext";
+import { defaultEmail } from "../default-email";
+import {
+ addMailToSequence,
+ createEmailTemplate,
+ createSequence,
+ getEmailTemplates,
+ getSystemEmailTemplates,
+ updateEmailTemplate,
+} from "../logic";
+import SequenceModel from "@/models/Sequence";
+
+const { permissions } = constants;
+
+describe("createEmailTemplate", () => {
+ let domain: any;
+ let ctx: GQLContext;
+
+ beforeAll(async () => {
+ domain = await DomainModel.create({
+ name: `mail-template-domain-${Date.now()}-${Math.floor(Math.random() * 100000)}`,
+ email: "owner@example.com",
+ });
+ });
+
+ beforeEach(async () => {
+ ctx = {
+ subdomain: domain,
+ user: {
+ userId: "admin-user",
+ permissions: [permissions.manageUsers],
+ },
+ address: "https://example.com",
+ } as unknown as GQLContext;
+
+ await EmailTemplateModel.deleteMany({ domain: domain._id });
+ await SequenceModel.deleteMany({ domain: domain._id });
+ });
+
+ afterAll(async () => {
+ await EmailTemplateModel.deleteMany({ domain: domain._id });
+ await SequenceModel.deleteMany({ domain: domain._id });
+ await DomainModel.deleteMany({ _id: domain._id });
+ });
+
+ it("creates a unique title when the default title already exists", async () => {
+ await EmailTemplateModel.create({
+ domain: domain._id,
+ templateId: "template-1",
+ title: "Blank",
+ creatorId: "existing-user",
+ content: defaultEmail,
+ });
+
+ const template = await createEmailTemplate({
+ templateId: "system-5",
+ context: ctx,
+ });
+
+ expect(template.title).toBe("Blank 2");
+ });
+
+ it("fills the next available numeric suffix", async () => {
+ await EmailTemplateModel.create([
+ {
+ domain: domain._id,
+ templateId: "template-1",
+ title: "Blank",
+ creatorId: "existing-user",
+ content: defaultEmail,
+ },
+ {
+ domain: domain._id,
+ templateId: "template-2",
+ title: "Blank 2",
+ creatorId: "existing-user",
+ content: defaultEmail,
+ },
+ ]);
+
+ const template = await createEmailTemplate({
+ templateId: "system-5",
+ context: ctx,
+ });
+
+ expect(template.title).toBe("Blank 3");
+ });
+
+ it("creates a template from selected starter content", async () => {
+ await EmailTemplateModel.create({
+ domain: domain._id,
+ templateId: "template-starter-source",
+ title: "Starter source",
+ creatorId: "existing-user",
+ content: defaultEmail,
+ });
+
+ const template = await createEmailTemplate({
+ templateId: "template-starter-source",
+ context: ctx,
+ });
+
+ expect(template.title).toBe("Starter source 2");
+ expect(template.content?.meta?.previewText).toBe(
+ defaultEmail.meta.previewText,
+ );
+ });
+
+ it("shows a friendly error when renaming a template to an existing title", async () => {
+ await EmailTemplateModel.create([
+ {
+ domain: domain._id,
+ templateId: "template-1",
+ title: "Welcome template",
+ creatorId: "existing-user",
+ content: defaultEmail,
+ },
+ {
+ domain: domain._id,
+ templateId: "template-2",
+ title: "Follow up template",
+ creatorId: "existing-user",
+ content: defaultEmail,
+ },
+ ]);
+
+ await expect(
+ updateEmailTemplate({
+ templateId: "template-2",
+ title: "Welcome template",
+ context: ctx,
+ }),
+ ).rejects.toThrow(responses.email_template_already_exists);
+ });
+
+ it("adds a new sequence email from a selected template id", async () => {
+ await SequenceModel.create({
+ domain: domain._id,
+ sequenceId: "sequence-1",
+ title: "Welcome flow",
+ type: "sequence",
+ from: {
+ name: "Admin",
+ email: "admin@example.com",
+ },
+ trigger: {
+ type: "subscriber:added",
+ },
+ emails: [],
+ emailsOrder: [],
+ creatorId: "admin-user",
+ });
+
+ await EmailTemplateModel.create({
+ domain: domain._id,
+ templateId: "template-launch-1",
+ title: "Launch email",
+ creatorId: "admin-user",
+ content: defaultEmail,
+ });
+
+ const sequence = await addMailToSequence(
+ ctx,
+ "sequence-1",
+ "template-launch-1",
+ );
+
+ expect(sequence?.emails).toHaveLength(1);
+ expect(sequence?.emails[0].subject).toBe("Launch email");
+ expect(sequence?.emails[0].content?.meta?.previewText).toBe(
+ defaultEmail.meta.previewText,
+ );
+ });
+
+ it("returns templates in reverse chronological order", async () => {
+ await EmailTemplateModel.create({
+ domain: domain._id,
+ templateId: "template-1",
+ title: "Older template",
+ creatorId: "existing-user",
+ content: defaultEmail,
+ });
+
+ await EmailTemplateModel.create({
+ domain: domain._id,
+ templateId: "template-2",
+ title: "Newer template",
+ creatorId: "existing-user",
+ content: defaultEmail,
+ });
+
+ const templates = await getEmailTemplates({ context: ctx });
+
+ expect(templates.map((template) => template.title)).toEqual([
+ "Newer template",
+ "Older template",
+ ]);
+ });
+
+ it("returns discovered system email templates", async () => {
+ const templates = await getSystemEmailTemplates({ context: ctx });
+
+ expect(templates.length).toBeGreaterThan(0);
+ expect(
+ templates.some((template) => template.templateId === "system-5"),
+ ).toBe(true);
+ });
+
+ it("creates a sequence from a system template id", async () => {
+ const sequence = await createSequence(ctx, "sequence", "system-5");
+
+ expect(sequence?.title).toBe("Blank");
+ expect(sequence?.emails[0].subject).toBe("Blank");
+ expect(sequence?.emails[0].content).toMatchObject({
+ meta: {
+ previewText:
+ "A blank starter template with only content and unsubscribe placeholders.",
+ },
+ });
+ });
+
+ it("creates a sequence from a saved custom template id", async () => {
+ await EmailTemplateModel.create({
+ domain: domain._id,
+ templateId: "template-custom-1",
+ title: "Custom welcome",
+ creatorId: "admin-user",
+ content: defaultEmail,
+ });
+
+ const sequence = await createSequence(
+ ctx,
+ "broadcast",
+ "template-custom-1",
+ );
+
+ expect(sequence?.title).toBe("Custom welcome");
+ expect(sequence?.emails[0].subject).toBe("Custom welcome");
+ expect(sequence?.emails[0].content?.meta?.previewText).toBe(
+ defaultEmail.meta.previewText,
+ );
+ });
+});
diff --git a/apps/web/graphql/mails/logic.ts b/apps/web/graphql/mails/logic.ts
index c342e4fce..39782cd5b 100644
--- a/apps/web/graphql/mails/logic.ts
+++ b/apps/web/graphql/mails/logic.ts
@@ -1,6 +1,8 @@
import constants from "@config/constants";
+import { promises as fs } from "fs";
import GQLContext from "@models/GQLContext";
import UserModel from "@models/User";
+import path from "path";
import { error } from "../../services/logger";
import { createUser, getMembership } from "../users/logic";
import {
@@ -29,15 +31,91 @@ import { AdminSequence, InternalCourse } from "@courselit/orm-models";
import { User } from "@courselit/common-models";
import EmailDeliveryModel from "@models/EmailDelivery";
import EmailEventModel from "@models/EmailEvent";
-import EmailTemplate from "@models/EmailTemplate";
+import EmailTemplateModel from "@models/EmailTemplate";
import { defaultEmail } from "./default-email";
import { sanitizeEmail } from "@/lib/sanitize-email";
+import { isDuplicateKeyError } from "../pages/helpers";
const { permissions } = constants;
const isDateInFuture = (timestamp?: number) =>
typeof timestamp === "number" && timestamp > Date.now();
+type EmailTemplateContent = typeof defaultEmail;
+
+type SystemEmailTemplate = {
+ templateId: string;
+ title: string;
+ content: EmailTemplateContent;
+};
+
+async function getSystemTemplateEntries(): Promise {
+ const templatesDir = await getSystemTemplatesDir();
+ const realTemplatesDir = await fs.realpath(templatesDir);
+ const filenames = await fs.readdir(templatesDir);
+
+ const templates = filenames
+ .filter((filename) => path.extname(filename) === ".json")
+ .map(async (filename) => {
+ const filePath = path.join(templatesDir, filename);
+ const stats = await fs.lstat(filePath);
+ if (!stats.isFile() || stats.isSymbolicLink()) {
+ throw new Error(`Invalid template file: ${filename}`);
+ }
+
+ const realFilePath = await fs.realpath(filePath);
+ if (
+ realFilePath !== path.join(realTemplatesDir, filename) ||
+ !realFilePath.startsWith(`${realTemplatesDir}${path.sep}`)
+ ) {
+ throw new Error(`Template path escapes directory: ${filename}`);
+ }
+
+ const fileContents = await fs.readFile(realFilePath, "utf8");
+ return JSON.parse(fileContents) as SystemEmailTemplate;
+ });
+
+ return Promise.all(templates);
+}
+
+async function getSystemTemplatesDir() {
+ const candidates = [
+ path.join(process.cwd(), "templates/system-emails"),
+ path.join(process.cwd(), "apps/web/templates/system-emails"),
+ ];
+
+ for (const candidate of candidates) {
+ try {
+ await fs.access(candidate);
+ return candidate;
+ } catch {}
+ }
+
+ throw new Error("System email templates directory not found");
+}
+
+async function getTemplate({
+ templateId,
+ ctx,
+}: {
+ templateId: string;
+ ctx: GQLContext;
+}) {
+ const systemTemplates = await getSystemTemplateEntries();
+ const systemTemplate = systemTemplates.find(
+ (template) => template.templateId === templateId,
+ );
+
+ if (systemTemplate) {
+ return systemTemplate;
+ }
+
+ return EmailTemplateModel.findOne({
+ templateId,
+ domain: ctx.subdomain._id,
+ });
+}
+
export async function createSubscription(
name: string,
email: string,
@@ -96,8 +174,7 @@ const defaultEmailContent = {
export async function createSequence(
ctx: GQLContext,
type: (typeof Constants.mailTypes)[number],
- title?: string,
- content?: string,
+ templateId: string,
): Promise<(Sequence & { creatorId: string }) | null> {
checkIfAuthenticated(ctx);
@@ -106,21 +183,24 @@ export async function createSequence(
}
try {
+ const template = await getTemplate({ templateId, ctx });
+ if (!template) {
+ throw new Error(responses.item_not_found);
+ }
+
const emailId = generateUniqueId();
const sequenceObj: Partial = {
domain: ctx.subdomain._id,
type,
status: Constants.sequenceStatus[0],
- title: title || internal.default_email_sequence_name,
+ title: template.title || internal.default_email_sequence_name,
creatorId: ctx.user.userId,
emails: [
{
emailId,
- content: content
- ? JSON.parse(content)
- : defaultEmailContent,
+ content: template.content || defaultEmailContent,
subject:
- title ||
+ template.title ||
(type === "broadcast"
? internal.default_email_broadcast_subject
: internal.default_email_sequence_subject),
@@ -650,6 +730,7 @@ export async function deleteMailFromSequence({
export async function addMailToSequence(
ctx: GQLContext,
sequenceId: string,
+ templateId: string,
): Promise {
checkIfAuthenticated(ctx);
@@ -670,11 +751,10 @@ export async function addMailToSequence(
throw new Error(responses.action_not_allowed);
}
- // const lastEmail = sequence.emails.find(
- // (email) =>
- // email.emailId ===
- // sequence.emailsOrder[sequence.emailsOrder.length - 1],
- // );
+ const template = await getTemplate({ templateId, ctx });
+ if (!template) {
+ throw new Error(responses.item_not_found);
+ }
const emailId = generateUniqueId();
const oneDayInMillis = +(
@@ -682,8 +762,8 @@ export async function addMailToSequence(
);
const email = {
emailId,
- content: defaultEmailContent,
- subject: internal.default_email_sequence_subject,
+ content: template.content || defaultEmailContent,
+ subject: template.title || internal.default_email_sequence_subject,
delayInMillis: oneDayInMillis,
};
@@ -1024,10 +1104,10 @@ export async function getSubscribersCount({
}
export async function createEmailTemplate({
- title,
+ templateId,
context,
}: {
- title: string;
+ templateId: string;
context: GQLContext;
}) {
checkIfAuthenticated(context);
@@ -1036,18 +1116,76 @@ export async function createEmailTemplate({
throw new Error(responses.action_not_allowed);
}
- const template = new EmailTemplate({
+ const sourceTemplate = await getTemplate({
+ templateId,
+ ctx: context,
+ });
+ if (!sourceTemplate) {
+ throw new Error(responses.item_not_found);
+ }
+
+ const uniqueTitle = await getUniqueEmailTemplateTitle(
+ context.subdomain._id,
+ sourceTemplate.title,
+ );
+ const newTemplateId = generateUniqueId();
+
+ const template = new EmailTemplateModel({
domain: context.subdomain._id,
- title,
+ templateId: newTemplateId,
+ title: uniqueTitle,
creatorId: context.user.userId,
- content: defaultEmailContent,
+ content: sourceTemplate.content || defaultEmailContent,
});
- await template.save();
+ try {
+ await template.save();
+ } catch (err: any) {
+ if (isDuplicateKeyError(err)) {
+ throw new Error(responses.email_template_already_exists);
+ }
+
+ throw err;
+ }
return template;
}
+async function getUniqueEmailTemplateTitle(
+ domainId: string | { toString(): string },
+ baseTitle: string,
+) {
+ const existingTemplates = await EmailTemplateModel.find({
+ domain: domainId,
+ title: {
+ $regex: new RegExp(
+ `^${escapeRegExp(baseTitle)}(?:\\s(\\d+))?$`,
+ "i",
+ ),
+ },
+ })
+ .select("title")
+ .lean();
+
+ const titles = new Set(existingTemplates.map((template) => template.title));
+
+ if (!titles.has(baseTitle)) {
+ return baseTitle;
+ }
+
+ let suffix = 2;
+
+ while (titles.has(`${baseTitle} ${suffix}`)) {
+ suffix += 1;
+ }
+
+ return `${baseTitle} ${suffix}`;
+}
+
+function escapeRegExp(value: string) {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
export async function updateEmailTemplate({
templateId,
title,
@@ -1065,7 +1203,7 @@ export async function updateEmailTemplate({
throw new Error(responses.action_not_allowed);
}
- const template = await EmailTemplate.findOne({
+ const template = await EmailTemplateModel.findOne({
templateId,
domain: context.subdomain._id,
});
@@ -1075,6 +1213,16 @@ export async function updateEmailTemplate({
}
if (title) {
+ const existingTemplate = await EmailTemplateModel.findOne({
+ domain: context.subdomain._id,
+ title,
+ templateId: { $ne: templateId },
+ })
+ .select("_id")
+ .lean();
+ if (existingTemplate) {
+ throw new Error(responses.email_template_already_exists);
+ }
template.title = title;
}
@@ -1082,7 +1230,15 @@ export async function updateEmailTemplate({
template.content = JSON.parse(content);
}
- await template.save();
+ try {
+ await template.save();
+ } catch (err: any) {
+ if (isDuplicateKeyError(err)) {
+ throw new Error(responses.email_template_already_exists);
+ }
+
+ throw err;
+ }
return template;
}
@@ -1100,7 +1256,7 @@ export async function deleteEmailTemplate({
throw new Error(responses.action_not_allowed);
}
- await EmailTemplate.deleteOne({
+ await EmailTemplateModel.deleteOne({
templateId,
domain: context.subdomain._id,
});
@@ -1121,7 +1277,7 @@ export async function getEmailTemplate({
throw new Error(responses.action_not_allowed);
}
- const template = await EmailTemplate.findOne({
+ const template = await EmailTemplateModel.findOne({
templateId,
domain: context.subdomain._id,
});
@@ -1136,9 +1292,25 @@ export async function getEmailTemplates({ context }: { context: GQLContext }) {
throw new Error(responses.action_not_allowed);
}
- const templates = await EmailTemplate.find({
+ const templates = await EmailTemplateModel.find({
domain: context.subdomain._id,
- });
+ }).sort({ createdAt: -1, _id: -1 });
+
+ return templates;
+}
+
+export async function getSystemEmailTemplates({
+ context,
+}: {
+ context: GQLContext;
+}) {
+ checkIfAuthenticated(context);
+
+ if (!checkPermission(context.user.permissions, [permissions.manageUsers])) {
+ throw new Error(responses.action_not_allowed);
+ }
+
+ const templates = await getSystemTemplateEntries();
return templates;
}
diff --git a/apps/web/graphql/mails/mutation.ts b/apps/web/graphql/mails/mutation.ts
index 011696a44..36d4436c5 100644
--- a/apps/web/graphql/mails/mutation.ts
+++ b/apps/web/graphql/mails/mutation.ts
@@ -48,33 +48,37 @@ const mutations = {
type: types.sequence,
args: {
type: { type: new GraphQLNonNull(types.sequenceType) },
- title: { type: GraphQLString },
- content: { type: GraphQLString },
+ templateId: { type: new GraphQLNonNull(GraphQLString) },
},
resolve: async (
_: any,
{
type,
- title,
- content,
+ templateId,
}: {
type: (typeof Constants.mailTypes)[number];
- title?: string;
- content?: string;
+ templateId: string;
},
context: GQLContext,
- ) => createSequence(context, type, title, content),
+ ) => createSequence(context, type, templateId),
},
addMailToSequence: {
type: types.sequence,
args: {
sequenceId: { type: new GraphQLNonNull(GraphQLString) },
+ templateId: { type: new GraphQLNonNull(GraphQLString) },
},
resolve: async (
_: any,
- { sequenceId }: { sequenceId: string },
+ {
+ sequenceId,
+ templateId,
+ }: {
+ sequenceId: string;
+ templateId: string;
+ },
context: GQLContext,
- ) => addMailToSequence(context, sequenceId),
+ ) => addMailToSequence(context, sequenceId, templateId),
},
deleteMailFromSequence: {
type: types.sequence,
@@ -339,13 +343,13 @@ const mutations = {
createEmailTemplate: {
type: types.emailTemplate,
args: {
- title: { type: new GraphQLNonNull(GraphQLString) },
+ templateId: { type: new GraphQLNonNull(GraphQLString) },
},
resolve: async (
_: any,
- { title }: { title: string },
+ { templateId }: { templateId: string },
context: GQLContext,
- ) => createEmailTemplate({ title, context }),
+ ) => createEmailTemplate({ templateId, context }),
},
updateEmailTemplate: {
diff --git a/apps/web/graphql/mails/query.ts b/apps/web/graphql/mails/query.ts
index 4eec2198a..a16a4292b 100644
--- a/apps/web/graphql/mails/query.ts
+++ b/apps/web/graphql/mails/query.ts
@@ -19,6 +19,7 @@ import {
getSubscribersCount,
getEmailTemplate,
getEmailTemplates,
+ getSystemEmailTemplates,
} from "./logic";
import GQLContext from "../../models/GQLContext";
import { SequenceType } from "@courselit/common-models";
@@ -151,7 +152,6 @@ const queries = {
context: GQLContext,
) => getSubscribersCount({ ctx: context, sequenceId }),
},
-
getEmailTemplate: {
type: types.emailTemplate,
args: {
@@ -163,12 +163,16 @@ const queries = {
context: GQLContext,
) => getEmailTemplate({ templateId, context }),
},
-
getEmailTemplates: {
type: new GraphQLList(types.emailTemplate),
resolve: (_: any, {}: {}, context: GQLContext) =>
getEmailTemplates({ context }),
},
+ getSystemEmailTemplates: {
+ type: new GraphQLList(types.emailTemplate),
+ resolve: (_: any, {}: {}, context: GQLContext) =>
+ getSystemEmailTemplates({ context }),
+ },
};
export default queries;
diff --git a/apps/web/templates/system-emails/announcement.json b/apps/web/templates/system-emails/announcement.json
new file mode 100644
index 000000000..1669532c1
--- /dev/null
+++ b/apps/web/templates/system-emails/announcement.json
@@ -0,0 +1,207 @@
+{
+ "templateId": "system-1",
+ "title": "Announcement",
+ "content": {
+ "style": {
+ "colors": {
+ "background": "#fdf2f8",
+ "foreground": "#111827",
+ "border": "#fbcfe8",
+ "accent": "#db2777",
+ "accentForeground": "#ffffff"
+ },
+ "typography": {
+ "header": {
+ "fontFamily": "Helvetica, sans-serif",
+ "letterSpacing": "-0.4px",
+ "textTransform": "none",
+ "textDecoration": "none"
+ },
+ "text": {
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "16px",
+ "lineHeight": "1.7",
+ "letterSpacing": "0px",
+ "textTransform": "none",
+ "textDecoration": "none"
+ },
+ "link": {
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "14px",
+ "lineHeight": "1.5",
+ "letterSpacing": "0.3px",
+ "textTransform": "uppercase",
+ "textDecoration": "none"
+ }
+ },
+ "interactives": {
+ "button": {
+ "padding": {
+ "x": "22px",
+ "y": "12px"
+ },
+ "border": {
+ "width": "0px",
+ "radius": "999px",
+ "style": "solid"
+ }
+ },
+ "link": {
+ "padding": {
+ "x": "0px",
+ "y": "0px"
+ }
+ }
+ },
+ "structure": {
+ "page": {
+ "background": "#ffffff",
+ "foreground": "#111827",
+ "width": "640px",
+ "marginY": "24px",
+ "borderWidth": "1px",
+ "borderStyle": "solid",
+ "borderRadius": "24px"
+ },
+ "section": {
+ "padding": {
+ "x": "32px",
+ "y": "20px"
+ }
+ }
+ }
+ },
+ "meta": {
+ "previewText": "A polished announcement template for launches, updates, and key news."
+ },
+ "content": [
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "JUST ANNOUNCED",
+ "alignment": "center",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "12px",
+ "foregroundColor": "#db2777",
+ "paddingTop": "6px",
+ "paddingBottom": "4px"
+ }
+ },
+ {
+ "blockType": "image",
+ "settings": {
+ "src": "https://images.pexels.com/photos/3184291/pexels-photo-3184291.jpeg?auto=compress&cs=tinysrgb&dpr=2&w=1200",
+ "alt": "Team collaborating in an office",
+ "alignment": "center",
+ "width": "100%",
+ "maxWidth": "100%",
+ "paddingTop": "8px",
+ "paddingBottom": "12px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "# Make your next announcement feel premium",
+ "alignment": "left",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "26px",
+ "foregroundColor": "#111827",
+ "paddingTop": "8px",
+ "paddingBottom": "10px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "This layout is designed for launches, waitlist openings, seasonal updates, and event drops where the headline and CTA need to carry the message clearly.",
+ "alignment": "left",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "17px",
+ "lineHeight": "1.7",
+ "foregroundColor": "#475569",
+ "paddingTop": "0px",
+ "paddingBottom": "10px"
+ }
+ },
+ {
+ "blockType": "link",
+ "settings": {
+ "text": "Claim your spot",
+ "url": "#",
+ "alignment": "left",
+ "isButton": true,
+ "buttonColor": "#db2777",
+ "buttonTextColor": "#ffffff",
+ "buttonBorderRadius": "999px",
+ "buttonPaddingX": "22px",
+ "buttonPaddingY": "12px",
+ "buttonBorderWidth": "0px",
+ "buttonBorderStyle": "solid",
+ "buttonBorderColor": "#db2777",
+ "paddingTop": "6px",
+ "paddingBottom": "16px"
+ }
+ },
+ {
+ "blockType": "separator",
+ "settings": {
+ "color": "#fbcfe8",
+ "thickness": "1px",
+ "style": "solid",
+ "paddingTop": "0px",
+ "paddingBottom": "14px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "## Why this template works\n\n- Fast visual hierarchy.\n- Clean body copy.\n- Strong CTA treatment.\n- Enough polish to feel current without being overdesigned.",
+ "alignment": "left",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "16px",
+ "lineHeight": "1.7",
+ "foregroundColor": "#111827",
+ "paddingTop": "0px",
+ "paddingBottom": "8px"
+ }
+ },
+ {
+ "blockType": "link",
+ "settings": {
+ "text": "Learn more",
+ "url": "#",
+ "alignment": "left",
+ "isButton": false,
+ "textColor": "#db2777",
+ "fontSize": "14px",
+ "textDecoration": "none",
+ "paddingTop": "0px",
+ "paddingBottom": "0px"
+ }
+ },
+ {
+ "blockType": "separator",
+ "settings": {
+ "color": "#fce7f3",
+ "thickness": "1px",
+ "style": "solid",
+ "paddingTop": "18px",
+ "paddingBottom": "10px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "{{address}}\n\n[Unsubscribe]({{unsubscribe_link}})",
+ "alignment": "center",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "12px",
+ "foregroundColor": "#64748b",
+ "paddingTop": "0px",
+ "paddingBottom": "0px"
+ }
+ }
+ ]
+ }
+}
diff --git a/apps/web/templates/system-emails/blank.json b/apps/web/templates/system-emails/blank.json
new file mode 100644
index 000000000..ceba64797
--- /dev/null
+++ b/apps/web/templates/system-emails/blank.json
@@ -0,0 +1,104 @@
+{
+ "templateId": "system-5",
+ "title": "Blank",
+ "content": {
+ "style": {
+ "colors": {
+ "background": "#ffffff",
+ "foreground": "#111827",
+ "border": "#ffffff",
+ "accent": "#2563eb",
+ "accentForeground": "#ffffff"
+ },
+ "typography": {
+ "header": {
+ "fontFamily": "Helvetica, sans-serif",
+ "letterSpacing": "0px",
+ "textTransform": "none",
+ "textDecoration": "none"
+ },
+ "text": {
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "16px",
+ "lineHeight": "1.7",
+ "letterSpacing": "0px",
+ "textTransform": "none",
+ "textDecoration": "none"
+ },
+ "link": {
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "14px",
+ "lineHeight": "1.5",
+ "letterSpacing": "0px",
+ "textTransform": "none",
+ "textDecoration": "none"
+ }
+ },
+ "interactives": {
+ "button": {
+ "padding": {
+ "x": "20px",
+ "y": "10px"
+ },
+ "border": {
+ "width": "0px",
+ "radius": "8px",
+ "style": "solid"
+ }
+ },
+ "link": {
+ "padding": {
+ "x": "0px",
+ "y": "0px"
+ }
+ }
+ },
+ "structure": {
+ "page": {
+ "background": "#ffffff",
+ "foreground": "#111827",
+ "width": "640px",
+ "marginY": "24px",
+ "borderWidth": "0px",
+ "borderStyle": "solid",
+ "borderRadius": "0px"
+ },
+ "section": {
+ "padding": {
+ "x": "32px",
+ "y": "18px"
+ }
+ }
+ }
+ },
+ "meta": {
+ "previewText": "A blank starter template with only content and unsubscribe placeholders."
+ },
+ "content": [
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "# Start writing here\n\nAdd your message, links, and call to action.",
+ "alignment": "left",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "16px",
+ "foregroundColor": "#111827",
+ "paddingTop": "12px",
+ "paddingBottom": "12px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "{{address}}\n\n[Unsubscribe]({{unsubscribe_link}})",
+ "alignment": "center",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "12px",
+ "foregroundColor": "#64748b",
+ "paddingTop": "8px",
+ "paddingBottom": "0px"
+ }
+ }
+ ]
+ }
+}
diff --git a/apps/web/templates/system-emails/new-user-welcome.json b/apps/web/templates/system-emails/new-user-welcome.json
new file mode 100644
index 000000000..66875f95c
--- /dev/null
+++ b/apps/web/templates/system-emails/new-user-welcome.json
@@ -0,0 +1,183 @@
+{
+ "templateId": "system-2",
+ "title": "New user welcome",
+ "content": {
+ "style": {
+ "colors": {
+ "background": "#f8fafc",
+ "foreground": "#111827",
+ "border": "#dbe4ee",
+ "accent": "#2563eb",
+ "accentForeground": "#ffffff"
+ },
+ "typography": {
+ "header": {
+ "fontFamily": "Helvetica, sans-serif",
+ "letterSpacing": "-0.2px",
+ "textTransform": "none",
+ "textDecoration": "none"
+ },
+ "text": {
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "16px",
+ "lineHeight": "1.7",
+ "letterSpacing": "0px",
+ "textTransform": "none",
+ "textDecoration": "none"
+ },
+ "link": {
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "14px",
+ "lineHeight": "1.5",
+ "letterSpacing": "0.2px",
+ "textTransform": "uppercase",
+ "textDecoration": "none"
+ }
+ },
+ "interactives": {
+ "button": {
+ "padding": {
+ "x": "22px",
+ "y": "12px"
+ },
+ "border": {
+ "width": "0px",
+ "radius": "999px",
+ "style": "solid"
+ }
+ },
+ "link": {
+ "padding": {
+ "x": "0px",
+ "y": "0px"
+ }
+ }
+ },
+ "structure": {
+ "page": {
+ "background": "#ffffff",
+ "foreground": "#111827",
+ "width": "640px",
+ "marginY": "24px",
+ "borderWidth": "1px",
+ "borderStyle": "solid",
+ "borderRadius": "20px"
+ },
+ "section": {
+ "padding": {
+ "x": "32px",
+ "y": "18px"
+ }
+ }
+ }
+ },
+ "meta": {
+ "previewText": "A friendly welcome email for onboarding new users and subscribers."
+ },
+ "content": [
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "WELCOME",
+ "alignment": "center",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "12px",
+ "foregroundColor": "#2563eb",
+ "paddingTop": "8px",
+ "paddingBottom": "0px"
+ }
+ },
+ {
+ "blockType": "image",
+ "settings": {
+ "src": "https://upload.wikimedia.org/wikipedia/commons/d/d5/Slack_icon_2019.svg",
+ "alt": "Slack logo",
+ "alignment": "center",
+ "width": "48px",
+ "maxWidth": "100%",
+ "paddingTop": "10px",
+ "paddingBottom": "6px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "# Welcome aboard",
+ "alignment": "left",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "26px",
+ "foregroundColor": "#111827",
+ "paddingTop": "6px",
+ "paddingBottom": "10px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "Thanks for joining us. This template works well for onboarding, first-touch education, getting-started checklists, and setting expectations for what comes next.",
+ "alignment": "left",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "17px",
+ "lineHeight": "1.7",
+ "foregroundColor": "#475569",
+ "paddingTop": "0px",
+ "paddingBottom": "10px"
+ }
+ },
+ {
+ "blockType": "link",
+ "settings": {
+ "text": "Get started",
+ "url": "#",
+ "alignment": "left",
+ "isButton": true,
+ "buttonColor": "#2563eb",
+ "buttonTextColor": "#ffffff",
+ "buttonBorderRadius": "999px",
+ "buttonPaddingX": "22px",
+ "buttonPaddingY": "12px",
+ "buttonBorderWidth": "0px",
+ "buttonBorderStyle": "solid",
+ "buttonBorderColor": "#2563eb",
+ "paddingTop": "6px",
+ "paddingBottom": "16px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "## What to do next\n\n- Complete your profile or setup.\n- Explore your dashboard or core feature.\n- Save this email for quick access later.",
+ "alignment": "left",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "16px",
+ "lineHeight": "1.7",
+ "foregroundColor": "#111827",
+ "paddingTop": "0px",
+ "paddingBottom": "8px"
+ }
+ },
+ {
+ "blockType": "separator",
+ "settings": {
+ "color": "#dbe4ee",
+ "thickness": "1px",
+ "style": "solid",
+ "paddingTop": "12px",
+ "paddingBottom": "10px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "{{address}}\n\n[Unsubscribe]({{unsubscribe_link}})",
+ "alignment": "center",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "12px",
+ "foregroundColor": "#64748b",
+ "paddingTop": "0px",
+ "paddingBottom": "0px"
+ }
+ }
+ ]
+ }
+}
diff --git a/apps/web/templates/system-emails/newsletter.json b/apps/web/templates/system-emails/newsletter.json
new file mode 100644
index 000000000..cbf9545bd
--- /dev/null
+++ b/apps/web/templates/system-emails/newsletter.json
@@ -0,0 +1,208 @@
+{
+ "templateId": "system-4",
+ "title": "Newsletter",
+ "content": {
+ "style": {
+ "colors": {
+ "background": "#f8fafc",
+ "foreground": "#0f172a",
+ "border": "#dbe4ee",
+ "accent": "#0f766e",
+ "accentForeground": "#ffffff"
+ },
+ "typography": {
+ "header": {
+ "fontFamily": "Arial, sans-serif",
+ "letterSpacing": "-0.2px",
+ "textTransform": "none",
+ "textDecoration": "none"
+ },
+ "text": {
+ "fontFamily": "Arial, sans-serif",
+ "fontSize": "16px",
+ "lineHeight": "1.7",
+ "letterSpacing": "0px",
+ "textTransform": "none",
+ "textDecoration": "none"
+ },
+ "link": {
+ "fontFamily": "Arial, sans-serif",
+ "fontSize": "14px",
+ "lineHeight": "1.5",
+ "letterSpacing": "0.2px",
+ "textTransform": "uppercase",
+ "textDecoration": "none"
+ }
+ },
+ "interactives": {
+ "button": {
+ "padding": {
+ "x": "20px",
+ "y": "11px"
+ },
+ "border": {
+ "width": "0px",
+ "radius": "8px",
+ "style": "solid"
+ }
+ },
+ "link": {
+ "padding": {
+ "x": "0px",
+ "y": "0px"
+ }
+ }
+ },
+ "structure": {
+ "page": {
+ "background": "#ffffff",
+ "foreground": "#0f172a",
+ "width": "640px",
+ "marginY": "24px",
+ "borderWidth": "1px",
+ "borderStyle": "solid",
+ "borderRadius": "20px"
+ },
+ "section": {
+ "padding": {
+ "x": "32px",
+ "y": "18px"
+ }
+ }
+ }
+ },
+ "meta": {
+ "previewText": "A clean, editorial newsletter for recurring updates and curated stories."
+ },
+ "content": [
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "WEEKLY NEWSLETTER",
+ "alignment": "left",
+ "fontFamily": "Arial, sans-serif",
+ "fontSize": "12px",
+ "foregroundColor": "#0f766e",
+ "paddingTop": "6px",
+ "paddingBottom": "0px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "# What happened this week",
+ "alignment": "left",
+ "fontFamily": "Arial, sans-serif",
+ "fontSize": "26px",
+ "foregroundColor": "#0f172a",
+ "paddingTop": "10px",
+ "paddingBottom": "10px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "Use this for curated updates, editorial roundups, key announcements, and useful links your audience will want to save.",
+ "alignment": "left",
+ "fontFamily": "Arial, sans-serif",
+ "fontSize": "17px",
+ "lineHeight": "1.7",
+ "foregroundColor": "#475569",
+ "paddingTop": "0px",
+ "paddingBottom": "14px"
+ }
+ },
+ {
+ "blockType": "separator",
+ "settings": {
+ "color": "#dbe4ee",
+ "thickness": "1px",
+ "style": "solid",
+ "paddingTop": "0px",
+ "paddingBottom": "14px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "## Featured update\n\nShare the biggest story or insight first. A short explanation plus one strong CTA is usually enough.",
+ "alignment": "left",
+ "fontFamily": "Arial, sans-serif",
+ "fontSize": "16px",
+ "lineHeight": "1.7",
+ "foregroundColor": "#0f172a",
+ "paddingTop": "0px",
+ "paddingBottom": "8px"
+ }
+ },
+ {
+ "blockType": "link",
+ "settings": {
+ "text": "Read the feature",
+ "url": "#",
+ "alignment": "left",
+ "isButton": true,
+ "buttonColor": "#0f766e",
+ "buttonTextColor": "#ffffff",
+ "buttonBorderRadius": "8px",
+ "buttonPaddingX": "20px",
+ "buttonPaddingY": "11px",
+ "buttonBorderWidth": "0px",
+ "buttonBorderStyle": "solid",
+ "buttonBorderColor": "#0f766e",
+ "paddingTop": "4px",
+ "paddingBottom": "16px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "## Also inside\n\n- A quick tip or takeaway.\n- One resource worth sharing.\n- A subtle CTA to your offer or archive.",
+ "alignment": "left",
+ "fontFamily": "Arial, sans-serif",
+ "fontSize": "16px",
+ "lineHeight": "1.7",
+ "foregroundColor": "#0f172a",
+ "paddingTop": "0px",
+ "paddingBottom": "8px"
+ }
+ },
+ {
+ "blockType": "link",
+ "settings": {
+ "text": "Browse past issues",
+ "url": "#",
+ "alignment": "left",
+ "isButton": false,
+ "textColor": "#0f766e",
+ "fontSize": "14px",
+ "textDecoration": "none",
+ "paddingTop": "0px",
+ "paddingBottom": "0px"
+ }
+ },
+ {
+ "blockType": "separator",
+ "settings": {
+ "color": "#dbe4ee",
+ "thickness": "1px",
+ "style": "solid",
+ "paddingTop": "18px",
+ "paddingBottom": "10px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "{{address}}\n\n[Unsubscribe]({{unsubscribe_link}})",
+ "alignment": "center",
+ "fontFamily": "Arial, sans-serif",
+ "fontSize": "12px",
+ "foregroundColor": "#64748b",
+ "paddingTop": "0px",
+ "paddingBottom": "0px"
+ }
+ }
+ ]
+ }
+}
diff --git a/apps/web/templates/system-emails/plain-text.json b/apps/web/templates/system-emails/plain-text.json
deleted file mode 100644
index 507ff124a..000000000
--- a/apps/web/templates/system-emails/plain-text.json
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "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
deleted file mode 100644
index 5919938b5..000000000
--- a/apps/web/templates/system-emails/simple-announcement.json
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "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/templates/system-emails/upsell-products.json b/apps/web/templates/system-emails/upsell-products.json
new file mode 100644
index 000000000..1f8025987
--- /dev/null
+++ b/apps/web/templates/system-emails/upsell-products.json
@@ -0,0 +1,198 @@
+{
+ "templateId": "system-3",
+ "title": "Upsell products",
+ "content": {
+ "style": {
+ "colors": {
+ "background": "#f8fafc",
+ "foreground": "#0f172a",
+ "border": "#dbe4ee",
+ "accent": "#7c3aed",
+ "accentForeground": "#ffffff"
+ },
+ "typography": {
+ "header": {
+ "fontFamily": "Helvetica, sans-serif",
+ "letterSpacing": "-0.3px",
+ "textTransform": "none",
+ "textDecoration": "none"
+ },
+ "text": {
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "16px",
+ "lineHeight": "1.7",
+ "letterSpacing": "0px",
+ "textTransform": "none",
+ "textDecoration": "none"
+ },
+ "link": {
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "14px",
+ "lineHeight": "1.5",
+ "letterSpacing": "0.3px",
+ "textTransform": "uppercase",
+ "textDecoration": "none"
+ }
+ },
+ "interactives": {
+ "button": {
+ "padding": {
+ "x": "22px",
+ "y": "12px"
+ },
+ "border": {
+ "width": "0px",
+ "radius": "999px",
+ "style": "solid"
+ }
+ },
+ "link": {
+ "padding": {
+ "x": "0px",
+ "y": "0px"
+ }
+ }
+ },
+ "structure": {
+ "page": {
+ "background": "#ffffff",
+ "foreground": "#0f172a",
+ "width": "640px",
+ "marginY": "24px",
+ "borderWidth": "1px",
+ "borderStyle": "solid",
+ "borderRadius": "20px"
+ },
+ "section": {
+ "padding": {
+ "x": "32px",
+ "y": "18px"
+ }
+ }
+ }
+ },
+ "meta": {
+ "previewText": "A polished upsell email to spotlight related products and premium offers."
+ },
+ "content": [
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "RECOMMENDED FOR YOU",
+ "alignment": "center",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "12px",
+ "foregroundColor": "#7c3aed",
+ "backgroundColor": "#f5f3ff",
+ "paddingTop": "8px",
+ "paddingBottom": "8px"
+ }
+ },
+ {
+ "blockType": "image",
+ "settings": {
+ "src": "https://upload.wikimedia.org/wikipedia/commons/d/d5/Slack_icon_2019.svg",
+ "alt": "Slack logo",
+ "alignment": "center",
+ "width": "56px",
+ "maxWidth": "100%",
+ "paddingTop": "12px",
+ "paddingBottom": "4px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "# Products your customers are ready to buy next",
+ "alignment": "left",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "26px",
+ "foregroundColor": "#0f172a",
+ "paddingTop": "8px",
+ "paddingBottom": "10px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "Use this for upgrades, bundles, complementary offers, limited-time incentives, and premium add-ons that naturally follow an initial purchase.",
+ "alignment": "left",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "17px",
+ "lineHeight": "1.7",
+ "foregroundColor": "#475569",
+ "paddingTop": "0px",
+ "paddingBottom": "10px"
+ }
+ },
+ {
+ "blockType": "link",
+ "settings": {
+ "text": "View recommended products",
+ "url": "#",
+ "alignment": "left",
+ "isButton": true,
+ "buttonColor": "#7c3aed",
+ "buttonTextColor": "#ffffff",
+ "buttonBorderRadius": "999px",
+ "buttonPaddingX": "22px",
+ "buttonPaddingY": "12px",
+ "buttonBorderWidth": "0px",
+ "buttonBorderStyle": "solid",
+ "buttonBorderColor": "#7c3aed",
+ "paddingTop": "6px",
+ "paddingBottom": "16px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "## Why this converts\n\n- Relevant add-on recommendation.\n- Clear next step.\n- A premium but simple visual treatment.\n- Plenty of room for benefits, pricing, or testimonials.",
+ "alignment": "left",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "16px",
+ "lineHeight": "1.7",
+ "foregroundColor": "#0f172a",
+ "paddingTop": "0px",
+ "paddingBottom": "8px"
+ }
+ },
+ {
+ "blockType": "link",
+ "settings": {
+ "text": "See pricing",
+ "url": "#",
+ "alignment": "left",
+ "isButton": false,
+ "textColor": "#7c3aed",
+ "fontSize": "14px",
+ "textDecoration": "none",
+ "paddingTop": "0px",
+ "paddingBottom": "0px"
+ }
+ },
+ {
+ "blockType": "separator",
+ "settings": {
+ "color": "#e9d5ff",
+ "thickness": "1px",
+ "style": "solid",
+ "paddingTop": "18px",
+ "paddingBottom": "10px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "{{address}}\n\n[Unsubscribe]({{unsubscribe_link}})",
+ "alignment": "center",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "12px",
+ "foregroundColor": "#64748b",
+ "paddingTop": "0px",
+ "paddingBottom": "0px"
+ }
+ }
+ ]
+ }
+}
diff --git a/apps/web/ui-config/strings.ts b/apps/web/ui-config/strings.ts
index dcfdefe03..482738880 100644
--- a/apps/web/ui-config/strings.ts
+++ b/apps/web/ui-config/strings.ts
@@ -539,6 +539,8 @@ export const COURSE_STUDENT_SEARCH_BY_TEXT = "Search student";
export const COURSE_STUDENT_NO_RECORDS = "No student found";
export const QUESTION_BUILDER_DELETE_TOOLTIP = "Delete question";
export const PAGE_HEADER_EDIT_MAIL = "Compose mail";
+export const PAGE_HEADER_EDIT_TEMPLATE = "Template";
+export const PAGE_HEADER_MANAGE = "Manage";
export const PAGE_HEADER_EDIT_SEQUENCE = "Sequence details";
export const BTN_SEND = "Send";
export const DIALOG_SEND_HEADER = "Send mail";
@@ -573,6 +575,41 @@ 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 PAGE_HEADER_CHOOSE_TEMPLATE = "Choose a template";
+export const BTN_EDIT_TEMPLATE = "Edit template";
+export const BTN_DELETE_TEMPLATE = "Delete template";
+export const TEMPLATE_NAME_LABEL = "Template name";
+export const TEMPLATE_NAME_PLACEHOLDER = "Untitled template";
+export const TEMPLATE_SETTINGS_DESCRIPTION =
+ "Update the template name here. Use the preview below to open the editor when you want to change the content.";
+export const TEMPLATE_MANAGE_DESCRIPTION = "Manage your template settings";
+export const TEMPLATE_PREVIEW_HEADER = "Template preview";
+export const TEMPLATE_PREVIEW_DESCRIPTION =
+ "Review your template content and open the editor when you want to make changes.";
+export const MAIL_TEMPLATE_CHOOSER_HEADING = "Choose a template";
+export const MAIL_TEMPLATE_CHOOSER_DESCRIPTION =
+ "Start from a polished layout and customize it in the editor.";
+export const MAIL_TEMPLATE_CHOOSER_SYSTEM_SECTION = "System";
+export const MAIL_TEMPLATE_CHOOSER_SYSTEM_DESCRIPTION =
+ "Built-in starters for common email styles and use cases.";
+export const MAIL_TEMPLATE_CHOOSER_CUSTOM_SECTION = "My templates";
+export const MAIL_TEMPLATE_CHOOSER_CUSTOM_DESCRIPTION =
+ "Your saved templates, ready to reuse.";
+export const TEMPLATES_EMPTY_STATE_TITLE = "No templates yet";
+export const TEMPLATES_EMPTY_STATE_DESCRIPTION =
+ "Start with a system template and customize it to build your own reusable library.";
+export const TEMPLATES_EMPTY_STATE_CTA = "Create from template";
+export const TEMPLATE_CHOOSER_CUSTOM_EMPTY_STATE_TITLE =
+ "No saved templates yet";
+export const TEMPLATE_CHOOSER_CUSTOM_EMPTY_STATE_DESCRIPTION =
+ "Create one from a system template above and it will show up here for reuse.";
+export const MAIL_TEMPLATE_CHOOSER_BADGE_SYSTEM = "Starter";
+export const MAIL_TEMPLATE_CHOOSER_BADGE_CUSTOM = "Custom";
+export const DELETE_TEMPLATE_DIALOG_HEADER = "Delete template";
+export const DELETE_TEMPLATE_DIALOG_DESCRIPTION =
+ "This will permanently delete the template. This action cannot be undone.";
+export const TOAST_TEMPLATE_SAVED = "Template updated";
+export const TOAST_TEMPLATE_DELETED = "Template deleted";
export const MAIL_TABLE_HEADER_SUBJECT = "Subject";
export const MAIL_TABLE_HEADER_TITLE = "Title";
export const MAIL_TABLE_HEADER_RECEPIENTS = "No. of recipients";
@@ -756,3 +793,5 @@ export const LOGIN_CODE_SENT_MESSAGE =
"We have emailed you a one time password.";
export const LESSON_EMBED_URL_LABEL = "Embed code";
export const LESSON_CONTENT_LABEL = "Content";
+export const EMAIL_EDITOR_EMAIL_EDIT_HEADER = "Editing email";
+export const EMAIL_EDITOR_TEMPLATE_EDIT_HEADER = "Editing template";
From 85dcc02810a66ab23710c233c14020625fce0dd4 Mon Sep 17 00:00:00 2001
From: Rajat
Date: Wed, 1 Apr 2026 10:48:14 +0530
Subject: [PATCH 4/6] Email templating system
---
.codex | 0
.gitignore | 3 +-
AGENTS.md | 1 +
apps/docs/src/pages/en/website/blocks.md | 40 +-
apps/docs/src/pages/en/website/themes.md | 36 +-
apps/web/__mocks__/nanoid.ts | 2 +-
.../dashboard/(sidebar)/mails/mail-hub.tsx | 2 +-
.../__tests__/new-mail-page-client.test.tsx | 273 +++++++++++++
.../mails/new/__tests__/page.test.tsx | 125 ++++++
.../mails/new/new-mail-page-client.tsx | 341 ++++++++++++++++
.../dashboard/(sidebar)/mails/new/page.tsx | 96 +++++
.../mails/new/template-email-preview.tsx | 250 ++++++++++++
.../(sidebar)/mails/sequence/[id]/page.tsx | 40 +-
.../(sidebar)/mails/template/[id]/page.tsx | 383 ++++++++++++++++++
.../(with-contexts)/dashboard/mail/layout.tsx | 2 +-
.../sequence/[sequenceId]/[mailId]/page.tsx | 3 +-
.../template/[id]/internal/page.tsx | 47 +--
.../{mails => mail}/template/[id]/page.tsx | 14 +-
.../mails/new/new-mail-page-client.tsx | 160 --------
.../dashboard/mails/new/page.tsx | 26 --
.../admin/dashboard-skeleton/app-sidebar.tsx | 22 +-
apps/web/components/admin/empty-state.tsx | 44 ++
.../components/admin/mails/email-viewer.tsx | 1 +
apps/web/components/admin/mails/index.tsx | 52 +--
.../components/admin/mails/templates-list.tsx | 85 ++--
apps/web/config/strings.ts | 1 +
.../web/graphql/mails/__tests__/logic.test.ts | 251 ++++++++++++
apps/web/graphql/mails/logic.ts | 226 +++++++++--
apps/web/graphql/mails/mutation.ts | 28 +-
apps/web/graphql/mails/query.ts | 8 +-
.../templates/system-emails/announcement.json | 207 ++++++++++
apps/web/templates/system-emails/blank.json | 104 +++++
.../system-emails/new-user-welcome.json | 183 +++++++++
.../templates/system-emails/newsletter.json | 208 ++++++++++
.../templates/system-emails/plain-text.json | 19 -
.../system-emails/simple-announcement.json | 19 -
.../system-emails/upsell-products.json | 198 +++++++++
apps/web/ui-config/strings.ts | 39 ++
38 files changed, 3083 insertions(+), 456 deletions(-)
delete mode 100644 .codex
create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/__tests__/new-mail-page-client.test.tsx
create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/__tests__/page.test.tsx
create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/new-mail-page-client.tsx
create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/page.tsx
create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/template-email-preview.tsx
create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/template/[id]/page.tsx
rename apps/web/app/(with-contexts)/dashboard/{mails => mail}/template/[id]/internal/page.tsx (85%)
rename apps/web/app/(with-contexts)/dashboard/{mails => mail}/template/[id]/page.tsx (86%)
delete mode 100644 apps/web/app/(with-contexts)/dashboard/mails/new/new-mail-page-client.tsx
delete mode 100644 apps/web/app/(with-contexts)/dashboard/mails/new/page.tsx
create mode 100644 apps/web/components/admin/empty-state.tsx
create mode 100644 apps/web/graphql/mails/__tests__/logic.test.ts
create mode 100644 apps/web/templates/system-emails/announcement.json
create mode 100644 apps/web/templates/system-emails/blank.json
create mode 100644 apps/web/templates/system-emails/new-user-welcome.json
create mode 100644 apps/web/templates/system-emails/newsletter.json
delete mode 100644 apps/web/templates/system-emails/plain-text.json
delete mode 100644 apps/web/templates/system-emails/simple-announcement.json
create mode 100644 apps/web/templates/system-emails/upsell-products.json
diff --git a/.codex b/.codex
deleted file mode 100644
index e69de29bb..000000000
diff --git a/.gitignore b/.gitignore
index b5c161398..30a7525a3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -44,4 +44,5 @@ report*.json
globalConfig.json
# CourseLit files
-domains_to_delete.txt
\ No newline at end of file
+domains_to_delete.txt
+.codex
diff --git a/AGENTS.md b/AGENTS.md
index 8c682be75..9efaa591c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -6,6 +6,7 @@
- Command for running tests: `pnpm test`.
- The project uses shadcn for building UI so stick to its conventions and design.
- In `apps/web` workspace, create a string first in `apps/web/config/strings.ts` and then import it in the `.tsx` files, instead of using inline strings.
+- For admin/dashboard empty states in `apps/web`, prefer reusing `apps/web/components/admin/empty-state.tsx` instead of creating one-off placeholder UIs.
- When working with forms, always use refs to keep the current state of the form's data and use it to enable/disable the form submit button.
- Check the name field inside each package's package.json to confirm the right name—skip the top-level one.
- While working with forms, always use zod and react-hook-form to validate the form. Take reference implementation from `apps/web/components/admin/settings/sso/new.tsx`.
diff --git a/apps/docs/src/pages/en/website/blocks.md b/apps/docs/src/pages/en/website/blocks.md
index 2b68f48ae..579434db1 100644
--- a/apps/docs/src/pages/en/website/blocks.md
+++ b/apps/docs/src/pages/en/website/blocks.md
@@ -17,7 +17,7 @@ CourseLit offers a wide range of page blocks so that you can build all sorts of
### [Header](#header)
-Expand to see Header block details
+ Expand to see Header block details
> This is a [shared block](#shared-blocks). All published changes to this block impact all pages on your website.
@@ -35,13 +35,13 @@ You will also see the newly added link on the header itself.
3. Click on the pencil icon against the newly added link to edit it as shown above.
4. Change the label (displayed as text on the header block) and the URL (where the user should be taken upon clicking the label on the header) and click `Done` to save.
- 
-
+
+
### [Rich Text](#rich-text)
-Expand to see Rich Text block details
+ Expand to see Rich Text block details
The rich text block uses the same text editor available elsewhere on the platform. It supports all functionality that does not require a toolbar, as the toolbar is hidden in this block.
@@ -68,13 +68,13 @@ The rich text block uses the same text editor available elsewhere on the platfor
1. Select the text.
2. Click on the floating `link` icon to reveal a text input.
3. In the popup text input, enter the URL as shown below and press Enter.
- 
-
+
+
### [Hero](#hero)
-Expand to see Hero block details
+ Expand to see Hero block details
A hero section of a web page is the section that immediately appears on screen, just under the header. The hero block helps you put the information front and center.
@@ -93,14 +93,14 @@ Following is how it looks on a page.
3. In the button text field, add the text that will be visible on the button.
4. In the button action, enter the URL the user should be taken to upon clicking.
- a. If the URL is from your own school, use its relative form, i.e., `/courses`.
- b. If the URL is from some external website, use the absolute (complete) URL, i.e., `https://website.com/courses`.
-
+a. If the URL is from your own school, use its relative form, i.e., `/courses`.
+b. If the URL is from some external website, use the absolute (complete) URL, i.e., `https://website.com/courses`.
+
### [Grid](#grid)
-Expand to see Grid block details
+ Expand to see Grid block details
A grid block comes in handy when you want to show some sort of list, for example, features list or advantages, etc. The list gets displayed in the grid format as shown below.
@@ -138,14 +138,14 @@ A grid block comes in handy when you want to show some sort of list, for example
3. In the button text field, add the text that will be visible on the button.
4. In the button action, enter the URL the user should be taken to upon clicking.
- a. If the URL is from your own school, use its relative form, i.e., `/courses`.
- b. If the URL is from some external website, use the absolute (complete) URL, i.e., `https://website.com/courses`.
-
+a. If the URL is from your own school, use its relative form, i.e., `/courses`.
+b. If the URL is from some external website, use the absolute (complete) URL, i.e., `https://website.com/courses`.
+
### [Featured](#featured)
-Expand to see Featured block details
+ Expand to see Featured block details
If you want to show your other products on a page, the featured widget is the one to use.
@@ -166,7 +166,7 @@ Following is how it looks on a page.
### [Curriculum](#curriculum)
-Expand to see Curriculum block details
+ Expand to see Curriculum block details
> This block can only be added to the products' sales pages.
@@ -187,7 +187,7 @@ Your audience can directly click on the lessons to see them in the course viewer
### [Banner](#banner)
-Expand to see Banner block details
+ Expand to see Banner block details
The banner block is the default block that shows the basic information about the page, i.e., on a sales page it shows the product's details like its title, description, featured image, and pricing, and on the homepage it shows your school's details like its name and subtitle.
@@ -229,7 +229,7 @@ Now, whenever your users enter their emails and press submit, they will see the
### [Newsletter signup](#newsletter-signup)
-Expand to see Newsletter signup block details
+ Expand to see Newsletter signup block details
Having a mailing list to sell directly to is a dream of every business, big or small. That's why CourseLit offers a dedicated block that lets you capture emails. It is also a [shared block](/en/pages/blocks#shared-page-blocks).
@@ -253,7 +253,7 @@ Following is an animation that shows the entire flow.
### [Embed](#embed)
-Expand to see Embed block details
+ Expand to see Embed block details
Embedding content from other websites is a common requirement. CourseLit offers a dedicated block that lets you embed content from other websites.
@@ -299,7 +299,7 @@ Here is [Cal.com](https://cal.com/)'s embed looks on a page.
### [Footer](#footer)
-Expand to see Footer block details
+ Expand to see Footer block details
> This is a [shared block](#shared-blocks). All published changes to this block impact all pages on your website.
diff --git a/apps/docs/src/pages/en/website/themes.md b/apps/docs/src/pages/en/website/themes.md
index 0700d2e29..5c704e4f2 100644
--- a/apps/docs/src/pages/en/website/themes.md
+++ b/apps/docs/src/pages/en/website/themes.md
@@ -192,14 +192,14 @@ The typography editor lets you customize text styles across your website. These
- Header 3: Smaller titles for subsections
- Header 4: Small titles for minor sections
- Preheader: Introductory text that appears above headers
-
+
Subheaders
- Subheader 1: Primary subheaders for section introductions
- Subheader 2: Secondary subheaders for supporting text
-
+
Body Text
@@ -207,7 +207,7 @@ The typography editor lets you customize text styles across your website. These
- Text 1: Main body text for content
- Text 2: Secondary body text for supporting content
- Caption: Small text for image captions and footnotes
-
+
Interactive Elements
@@ -215,7 +215,7 @@ The typography editor lets you customize text styles across your website. These
- Link: Text for clickable links
- Button: Text for buttons and calls-to-action
- Input: Text for form fields and search boxes
-
+
For each text style, you can customize:
@@ -243,7 +243,7 @@ CourseLit provides a carefully curated selection of professional fonts, organize
- **Mulish**: A geometric sans-serif with a modern feel
- **Nunito**: A well-balanced font with rounded terminals
- **Work Sans**: A clean, modern font with a geometric feel
-
+
Serif Fonts
@@ -253,7 +253,7 @@ CourseLit provides a carefully curated selection of professional fonts, organize
- **Playfair Display**: An elegant serif font for headings
- **Roboto Slab**: A serif variant of Roboto
- **Source Serif 4**: A serif font designed for digital reading
-
+
Display Fonts
@@ -264,7 +264,7 @@ CourseLit provides a carefully curated selection of professional fonts, organize
- **Rubik**: A sans-serif with a geometric feel
- **Oswald**: A reworking of the classic style
- **Bebas Neue**: A display font with a strong personality
-
+
Modern Fonts
@@ -272,7 +272,7 @@ CourseLit provides a carefully curated selection of professional fonts, organize
- **Lato**: A sans-serif font with a warm feel
- **PT Sans**: A font designed for public use
- **Quicksand**: A display sans-serif with rounded terminals
-
+
Each font is optimized for web use and includes multiple weights for flexibility in design. All fonts support Latin characters and are carefully selected for their readability and professional appearance.
@@ -290,7 +290,7 @@ The interactives editor allows you to customize the appearance of interactive el
- Shadow effects: From None to 2X Large
- Custom styles: Add your own custom styles using [supported Tailwind classes](#supported-tailwind-classes)
- Disabled state: How the button looks when it can't be clicked
-
+
Link
@@ -300,7 +300,7 @@ The interactives editor allows you to customize the appearance of interactive el
- Text shadow: Add depth to your links
- Custom styles: Add your own custom styles using [supported Tailwind classes](#supported-tailwind-classes)
- Disabled state: How the link looks when it can't be clicked
-
+
Card
@@ -309,7 +309,7 @@ The interactives editor allows you to customize the appearance of interactive el
- Border style: Choose from various border styles
- Shadow effects: Add depth to your cards
- Custom styles: Add your own custom styles using [supported Tailwind classes](#supported-tailwind-classes)
-
+
Input
@@ -320,7 +320,7 @@ The interactives editor allows you to customize the appearance of interactive el
- Shadow effects: Add depth to your input fields
- Custom styles: Add your own custom styles using [supported Tailwind classes](#supported-tailwind-classes)
- Disabled state: How the input looks when it can't be used
-
+
### 4. Structure
@@ -332,14 +332,14 @@ The structure editor lets you customize the layout of your pages, like section p
Page
- Maximum width options: - 2XL (42rem): Compact layout - 3XL (48rem): Standard layout - 4XL (56rem): Wide layout - 5XL (64rem): Extra wide layout - 6XL (72rem): Full width layout
-
+
Section
- Horizontal padding: Space on the left and right sides (None to 9X Large)
- Vertical padding: Space on the top and bottom (None to 9X Large)
-
+
## Publishing Changes
@@ -387,7 +387,7 @@ When adding custom styles to interactive elements, you can use the following Tai
- `text-6xl`: 6X large text
- `text-7xl`: 7X large text
- `text-8xl`: 8X large text
-
+
Padding
@@ -399,7 +399,7 @@ When adding custom styles to interactive elements, you can use the following Tai
#### Horizontal Padding
- `px-4` to `px-20`: Horizontal padding from 1rem to 5rem
-
+
Colors
@@ -454,7 +454,7 @@ Variants available: `hover`, `disabled`, `dark`
- `ease-out`: Ease out
- `ease-in-out`: Ease in and out
- `ease-linear`: Linear
-
+
Transforms
@@ -481,7 +481,7 @@ Variants available: `hover`, `disabled`, `dark`
- `scale-110`: 110% scale
- `scale-125`: 125% scale
- `scale-150`: 150% scale
-
+
Shadows
diff --git a/apps/web/__mocks__/nanoid.ts b/apps/web/__mocks__/nanoid.ts
index f634c2e79..4e4d2a3a4 100644
--- a/apps/web/__mocks__/nanoid.ts
+++ b/apps/web/__mocks__/nanoid.ts
@@ -1 +1 @@
-export const nanoid = jest.fn();
+export const nanoid = jest.fn(() => "mock-nanoid-id");
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/mail-hub.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/mail-hub.tsx
index 9822704e0..952af3713 100644
--- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/mail-hub.tsx
+++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/mail-hub.tsx
@@ -28,7 +28,7 @@ export default function MailHub() {
breadcrumbs={breadcrumbs}
permissions={[permissions.manageUsers]}
>
-
+
);
}
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/__tests__/new-mail-page-client.test.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/__tests__/new-mail-page-client.test.tsx
new file mode 100644
index 000000000..b7b3a408a
--- /dev/null
+++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/__tests__/new-mail-page-client.test.tsx
@@ -0,0 +1,273 @@
+import React from "react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import NewMailPageClient from "../new-mail-page-client";
+import { AddressContext, SiteInfoContext } from "@components/contexts";
+import {
+ MAIL_TEMPLATE_CHOOSER_CUSTOM_SECTION,
+ MAIL_TEMPLATE_CHOOSER_SYSTEM_SECTION,
+ TEMPLATE_CHOOSER_CUSTOM_EMPTY_STATE_DESCRIPTION,
+ TEMPLATE_CHOOSER_CUSTOM_EMPTY_STATE_TITLE,
+} from "@ui-config/strings";
+
+const mockToast = jest.fn();
+const mockPush = jest.fn();
+const mockExec = jest.fn();
+const mockSearchParams = new URLSearchParams();
+
+jest.mock("next/navigation", () => ({
+ useRouter: () => ({
+ push: mockPush,
+ }),
+ useSearchParams: () => ({
+ get: (key: string) => mockSearchParams.get(key),
+ }),
+}));
+
+jest.mock("@courselit/components-library", () => ({
+ useToast: () => ({
+ toast: mockToast,
+ }),
+}));
+
+jest.mock("@courselit/utils", () => {
+ const actual = jest.requireActual("@courselit/utils");
+ return {
+ ...actual,
+ FetchBuilder: jest.fn().mockImplementation(() => ({
+ setUrl: jest.fn().mockReturnThis(),
+ setPayload: jest.fn().mockReturnThis(),
+ setIsGraphQLEndpoint: jest.fn().mockReturnThis(),
+ build: jest.fn().mockReturnThis(),
+ exec: mockExec,
+ })),
+ };
+});
+
+jest.mock("../template-email-preview", () => ({
+ __esModule: true,
+ default: ({ content }: { content: any }) => (
+
+ {content?.meta?.previewText || "email-preview"}
+
+ ),
+}));
+
+jest.mock("@components/admin/empty-state", () => ({
+ __esModule: true,
+ default: ({
+ title,
+ description,
+ }: {
+ title: string;
+ description: string;
+ }) => (
+
+
{title}
+
{description}
+
+ ),
+}));
+
+jest.mock(
+ "@/components/ui/button",
+ () => ({
+ Button: ({
+ children,
+ ...props
+ }: React.ButtonHTMLAttributes) => (
+
+ ),
+ }),
+ { virtual: true },
+);
+
+jest.mock(
+ "@/components/ui/card",
+ () => ({
+ Card: ({
+ children,
+ onClick,
+ }: {
+ children: React.ReactNode;
+ onClick?: () => void;
+ }) => (
+
+ ),
+ CardContent: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ CardHeader: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ CardTitle: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ }),
+ { virtual: true },
+);
+
+jest.mock(
+ "@/components/ui/skeleton",
+ () => ({
+ Skeleton: () => ,
+ }),
+ { virtual: true },
+);
+
+const systemTemplate = {
+ templateId: "system-1",
+ title: "Announcement",
+ content: {
+ content: [],
+ style: {},
+ meta: { previewText: "system-template-preview" },
+ },
+};
+
+const customTemplate = {
+ templateId: "custom-1",
+ title: "Custom template",
+ content: {
+ content: [],
+ style: {},
+ meta: { previewText: "custom-template-preview" },
+ },
+};
+
+function renderPage() {
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+describe("NewMailPageClient", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockExec.mockReset();
+ mockPush.mockReset();
+ mockToast.mockReset();
+ mockSearchParams.forEach((_, key) => mockSearchParams.delete(key));
+ });
+
+ it("loads and renders system and custom templates", async () => {
+ mockSearchParams.set("type", "sequence");
+ mockExec.mockResolvedValueOnce({
+ systemTemplates: [systemTemplate],
+ templates: [customTemplate],
+ });
+
+ renderPage();
+
+ await waitFor(() => {
+ expect(screen.getByText("Announcement")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("Custom template")).toBeInTheDocument();
+ expect(
+ screen.getByText(MAIL_TEMPLATE_CHOOSER_SYSTEM_SECTION),
+ ).toBeInTheDocument();
+ });
+
+ it("creates a sequence from the selected template", async () => {
+ mockSearchParams.set("type", "sequence");
+ mockExec
+ .mockResolvedValueOnce({
+ systemTemplates: [systemTemplate],
+ templates: [],
+ })
+ .mockResolvedValueOnce({
+ sequence: {
+ sequenceId: "sequence-1",
+ },
+ });
+
+ renderPage();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(TEMPLATE_CHOOSER_CUSTOM_EMPTY_STATE_TITLE),
+ ).toBeInTheDocument();
+ });
+
+ expect(
+ screen.getByText(TEMPLATE_CHOOSER_CUSTOM_EMPTY_STATE_DESCRIPTION),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(MAIL_TEMPLATE_CHOOSER_CUSTOM_SECTION),
+ ).toBeInTheDocument();
+
+ fireEvent.click(screen.getByText(systemTemplate.title));
+
+ await waitFor(() => {
+ expect(mockPush).toHaveBeenCalledWith(
+ "/dashboard/mails/sequence/sequence-1",
+ );
+ });
+ });
+
+ it("adds a selected template to an existing sequence", async () => {
+ mockSearchParams.set("type", "sequence");
+ mockSearchParams.set("mode", "add-to-sequence");
+ mockSearchParams.set("sequenceId", "sequence-123");
+ mockExec
+ .mockResolvedValueOnce({
+ systemTemplates: [systemTemplate],
+ templates: [],
+ })
+ .mockResolvedValueOnce({
+ sequence: {
+ sequenceId: "sequence-123",
+ },
+ });
+
+ renderPage();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(TEMPLATE_CHOOSER_CUSTOM_EMPTY_STATE_TITLE),
+ ).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByText(systemTemplate.title));
+
+ await waitFor(() => {
+ expect(mockPush).toHaveBeenCalledWith(
+ "/dashboard/mails/sequence/sequence-123",
+ );
+ });
+ });
+});
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/__tests__/page.test.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/__tests__/page.test.tsx
new file mode 100644
index 000000000..6a4679928
--- /dev/null
+++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/__tests__/page.test.tsx
@@ -0,0 +1,125 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import NewMailPage from "../page";
+import {
+ BUTTON_CANCEL_TEXT,
+ PAGE_HEADER_CHOOSE_TEMPLATE,
+ PAGE_HEADER_EDIT_SEQUENCE,
+ SEQUENCES,
+ TEMPLATES,
+} from "@ui-config/strings";
+
+const mockDashboardContent = jest.fn(
+ ({
+ children,
+ breadcrumbs,
+ }: {
+ children: React.ReactNode;
+ breadcrumbs?: { label: string; href: string }[];
+ }) => (
+
+
+ {JSON.stringify(breadcrumbs || [])}
+
+ {children}
+
+ ),
+);
+
+jest.mock("@components/admin/dashboard-content", () => ({
+ __esModule: true,
+ default: (props: {
+ children: React.ReactNode;
+ breadcrumbs?: { label: string; href: string }[];
+ }) => mockDashboardContent(props),
+}));
+
+jest.mock("next/link", () => ({
+ __esModule: true,
+ default: ({
+ children,
+ href,
+ }: {
+ children: React.ReactNode;
+ href: string;
+ }) => {children},
+}));
+
+jest.mock(
+ "@/components/ui/button",
+ () => ({
+ Button: ({
+ children,
+ ...props
+ }: React.ButtonHTMLAttributes) => (
+
+ ),
+ }),
+ { virtual: true },
+);
+
+jest.mock("../new-mail-page-client", () => ({
+ __esModule: true,
+ default: () => ,
+}));
+
+describe("NewMailPage", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("renders sequence breadcrumbs and cancel link for add-to-sequence flow", async () => {
+ const element = await NewMailPage({
+ searchParams: Promise.resolve({
+ type: "sequence",
+ mode: "add-to-sequence",
+ sequenceId: "sequence-123",
+ source: "sequences",
+ }),
+ });
+
+ render(element);
+
+ expect(
+ screen.getByRole("heading", { name: PAGE_HEADER_CHOOSE_TEMPLATE }),
+ ).toBeInTheDocument();
+ expect(screen.getByTestId("new-mail-page-client")).toBeInTheDocument();
+
+ expect(screen.getByTestId("breadcrumbs-json")).toHaveTextContent(
+ JSON.stringify([
+ { label: SEQUENCES, href: `/dashboard/mails?tab=${SEQUENCES}` },
+ {
+ label: PAGE_HEADER_EDIT_SEQUENCE,
+ href: "/dashboard/mails/sequence/sequence-123",
+ },
+ { label: PAGE_HEADER_CHOOSE_TEMPLATE, href: "#" },
+ ]),
+ );
+
+ expect(
+ screen.getByRole("link", { name: BUTTON_CANCEL_TEXT }),
+ ).toHaveAttribute("href", "/dashboard/mails/sequence/sequence-123");
+ });
+
+ it("renders template breadcrumbs and cancel link for template flow", async () => {
+ const element = await NewMailPage({
+ searchParams: Promise.resolve({
+ type: "template",
+ source: "templates",
+ }),
+ });
+
+ render(element);
+
+ expect(screen.getByTestId("breadcrumbs-json")).toHaveTextContent(
+ JSON.stringify([
+ { label: TEMPLATES, href: `/dashboard/mails?tab=${TEMPLATES}` },
+ { label: PAGE_HEADER_CHOOSE_TEMPLATE, href: "#" },
+ ]),
+ );
+
+ expect(
+ screen.getByRole("link", { name: BUTTON_CANCEL_TEXT }),
+ ).toHaveAttribute("href", `/dashboard/mails?tab=${TEMPLATES}`);
+ });
+});
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/new-mail-page-client.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/new-mail-page-client.tsx
new file mode 100644
index 000000000..f56dae128
--- /dev/null
+++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/new-mail-page-client.tsx
@@ -0,0 +1,341 @@
+"use client";
+
+import { EmailTemplate } from "@courselit/common-models";
+import { useToast } from "@courselit/components-library";
+import { FetchBuilder } from "@courselit/utils";
+import {
+ MAIL_TEMPLATE_CHOOSER_CUSTOM_DESCRIPTION,
+ TEMPLATE_CHOOSER_CUSTOM_EMPTY_STATE_DESCRIPTION,
+ TEMPLATE_CHOOSER_CUSTOM_EMPTY_STATE_TITLE,
+ MAIL_TEMPLATE_CHOOSER_CUSTOM_SECTION,
+ MAIL_TEMPLATE_CHOOSER_SYSTEM_DESCRIPTION,
+ MAIL_TEMPLATE_CHOOSER_SYSTEM_SECTION,
+ TOAST_TITLE_ERROR,
+} from "@ui-config/strings";
+import { useEffect, useState } from "react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useRouter, useSearchParams } from "next/navigation";
+import { AddressContext } from "@components/contexts";
+import { useContext } from "react";
+import AdminEmptyState from "@components/admin/empty-state";
+import TemplateEmailPreview from "./template-email-preview";
+
+const sortSystemTemplates = (templates: EmailTemplate[]) =>
+ [...templates].sort((a, b) => {
+ if (a.title === "Blank") {
+ return 1;
+ }
+
+ if (b.title === "Blank") {
+ return -1;
+ }
+
+ return a.title.localeCompare(b.title);
+ });
+
+const TemplateGrid = ({
+ templates,
+ onTemplateClick,
+}: {
+ templates: EmailTemplate[];
+ onTemplateClick: (template: EmailTemplate) => void;
+}) => (
+
+ {templates.map((template) => (
+ onTemplateClick(template)}
+ >
+
+ {template.title}
+
+
+
+
+
+ ))}
+
+);
+
+const NewMailPageClient = () => {
+ const address = useContext(AddressContext);
+ const [systemTemplates, setSystemTemplates] = useState([]);
+ const [templates, setTemplates] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const { toast } = useToast();
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ const type = searchParams?.get("type");
+ const mode = searchParams?.get("mode");
+ const sequenceId = searchParams?.get("sequenceId");
+ const brandedSystemTemplates = sortSystemTemplates(systemTemplates);
+
+ const fetch = new FetchBuilder()
+ .setUrl(`${address.backend}/api/graph`)
+ .setIsGraphQLEndpoint(true);
+
+ useEffect(() => {
+ loadTemplates();
+ }, []);
+
+ const loadTemplates = async () => {
+ setIsLoading(true);
+ const query = `
+ query GetMailTemplates {
+ systemTemplates: getSystemEmailTemplates {
+ templateId
+ title
+ content {
+ content {
+ blockType
+ settings
+ }
+ style
+ meta
+ }
+ }
+ templates: getEmailTemplates {
+ templateId
+ title
+ content {
+ content {
+ blockType
+ settings
+ }
+ style
+ meta
+ }
+ }
+ }`;
+
+ const fetcher = fetch
+ .setPayload({
+ query,
+ })
+ .build();
+
+ try {
+ const response = await fetcher.exec();
+ if (response.systemTemplates) {
+ setSystemTemplates(response.systemTemplates);
+ }
+ if (response.templates) {
+ setTemplates(response.templates);
+ }
+ } catch (e: any) {
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description: e.message,
+ variant: "destructive",
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const createSequence = async (template: EmailTemplate) => {
+ const mutation = `
+ mutation createSequence(
+ $type: SequenceType!,
+ $templateId: String!
+ ) {
+ sequence: createSequence(type: $type, templateId: $templateId) {
+ sequenceId
+ }
+ }
+ `;
+ const fetch = new FetchBuilder()
+ .setUrl(`${address.backend}/api/graph`)
+ .setPayload({
+ query: mutation,
+ variables: {
+ type: type?.toUpperCase(),
+ templateId: template.templateId,
+ },
+ })
+ .setIsGraphQLEndpoint(true)
+ .build();
+ try {
+ 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",
+ });
+ }
+ };
+
+ const addTemplateToSequence = async (template: EmailTemplate) => {
+ if (!sequenceId) {
+ return;
+ }
+
+ const mutation = `
+ mutation AddMailToSequence(
+ $sequenceId: String!,
+ $templateId: String!
+ ) {
+ sequence: addMailToSequence(
+ sequenceId: $sequenceId,
+ templateId: $templateId
+ ) {
+ sequenceId
+ }
+ }
+ `;
+
+ const fetch = new FetchBuilder()
+ .setUrl(`${address.backend}/api/graph`)
+ .setPayload({
+ query: mutation,
+ variables: {
+ sequenceId,
+ templateId: template.templateId,
+ },
+ })
+ .setIsGraphQLEndpoint(true)
+ .build();
+
+ try {
+ const response = await fetch.exec();
+ if (response.sequence?.sequenceId) {
+ router.push(
+ `/dashboard/mails/sequence/${response.sequence.sequenceId}`,
+ );
+ }
+ } catch (err: any) {
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description: err.message,
+ variant: "destructive",
+ });
+ }
+ };
+
+ const createTemplateFromSelection = async (template: EmailTemplate) => {
+ const mutation = `
+ mutation CreateEmailTemplate($templateId: String!) {
+ template: createEmailTemplate(templateId: $templateId) {
+ templateId
+ }
+ }
+ `;
+
+ const fetch = new FetchBuilder()
+ .setUrl(`${address.backend}/api/graph`)
+ .setPayload({
+ query: mutation,
+ variables: {
+ templateId: template.templateId,
+ },
+ })
+ .setIsGraphQLEndpoint(true)
+ .build();
+
+ try {
+ const response = await fetch.exec();
+ if (response.template?.templateId) {
+ router.push(
+ `/dashboard/mails/template/${response.template.templateId}`,
+ );
+ }
+ } catch (err: any) {
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description: err.message,
+ variant: "destructive",
+ });
+ }
+ };
+
+ const onTemplateClick = (template: EmailTemplate) => {
+ if (mode === "add-to-sequence" && sequenceId) {
+ addTemplateToSequence(template);
+ return;
+ }
+
+ if (type === "template") {
+ createTemplateFromSelection(template);
+ return;
+ }
+
+ createSequence(template);
+ };
+
+ const skeletonCards = Array.from({ length: 6 });
+
+ return (
+
+
+
+
+ {MAIL_TEMPLATE_CHOOSER_SYSTEM_SECTION}
+
+
+ {MAIL_TEMPLATE_CHOOSER_SYSTEM_DESCRIPTION}
+
+
+
+
+
+
+
+ {MAIL_TEMPLATE_CHOOSER_CUSTOM_SECTION}
+
+
+ {MAIL_TEMPLATE_CHOOSER_CUSTOM_DESCRIPTION}
+
+
+ {isLoading ? (
+
+ {skeletonCards.map((_, idx) => (
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ ) : templates.length ? (
+
+ ) : (
+
+ )}
+
+
+ );
+};
+
+export default NewMailPageClient;
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/page.tsx
new file mode 100644
index 000000000..5ee9fe51b
--- /dev/null
+++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/page.tsx
@@ -0,0 +1,96 @@
+import DashboardContent from "@components/admin/dashboard-content";
+import { Button } from "@/components/ui/button";
+import NewMailPageClient from "./new-mail-page-client";
+import Link from "next/link";
+import {
+ BROADCASTS,
+ BUTTON_CANCEL_TEXT,
+ PAGE_HEADER_CHOOSE_TEMPLATE,
+ PAGE_HEADER_EDIT_SEQUENCE,
+ SEQUENCES,
+ TEMPLATES,
+} from "@ui-config/strings";
+
+const MAIL_KIND_BROADCAST = "broadcast";
+const MAIL_KIND_SEQUENCE = "sequence";
+const MAIL_KIND_TEMPLATE = "template";
+const NEW_MAIL_MODE_ADD_TO_SEQUENCE = "add-to-sequence";
+const NEW_MAIL_SOURCE_BROADCASTS = "broadcasts";
+const NEW_MAIL_SOURCE_SEQUENCES = "sequences";
+const NEW_MAIL_SOURCE_TEMPLATES = "templates";
+
+type MailKind =
+ | typeof MAIL_KIND_BROADCAST
+ | typeof MAIL_KIND_SEQUENCE
+ | typeof MAIL_KIND_TEMPLATE;
+type NewMailMode = typeof NEW_MAIL_MODE_ADD_TO_SEQUENCE;
+type NewMailSource =
+ | typeof NEW_MAIL_SOURCE_BROADCASTS
+ | typeof NEW_MAIL_SOURCE_SEQUENCES
+ | typeof NEW_MAIL_SOURCE_TEMPLATES;
+
+export default async function NewMailPage({
+ searchParams,
+}: {
+ searchParams: Promise<{
+ type?: MailKind;
+ mode?: NewMailMode;
+ sequenceId?: string;
+ source?: NewMailSource;
+ }>;
+}) {
+ const { type, mode, sequenceId, source } = await searchParams;
+ const isAddingToSequence =
+ mode === NEW_MAIL_MODE_ADD_TO_SEQUENCE && !!sequenceId;
+
+ const breadcrumbs = [
+ {
+ label:
+ type === MAIL_KIND_TEMPLATE
+ ? TEMPLATES
+ : type === MAIL_KIND_SEQUENCE
+ ? SEQUENCES
+ : BROADCASTS,
+ href:
+ type === MAIL_KIND_TEMPLATE
+ ? `/dashboard/mails?tab=${TEMPLATES}`
+ : `/dashboard/mails?tab=${type === MAIL_KIND_SEQUENCE ? SEQUENCES : BROADCASTS}`,
+ },
+ ...(isAddingToSequence
+ ? [
+ {
+ label: PAGE_HEADER_EDIT_SEQUENCE,
+ href: `/dashboard/mails/sequence/${sequenceId}`,
+ },
+ ]
+ : []),
+ {
+ label: PAGE_HEADER_CHOOSE_TEMPLATE,
+ href: "#",
+ },
+ ];
+
+ const cancelHref = isAddingToSequence
+ ? `/dashboard/mails/sequence/${sequenceId}`
+ : source === NEW_MAIL_SOURCE_TEMPLATES
+ ? `/dashboard/mails?tab=${TEMPLATES}`
+ : source === NEW_MAIL_SOURCE_SEQUENCES || type === MAIL_KIND_SEQUENCE
+ ? `/dashboard/mails?tab=${SEQUENCES}`
+ : `/dashboard/mails?tab=${BROADCASTS}`;
+
+ return (
+
+
+
+
+ {PAGE_HEADER_CHOOSE_TEMPLATE}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/template-email-preview.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/template-email-preview.tsx
new file mode 100644
index 000000000..4759868ea
--- /dev/null
+++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/new/template-email-preview.tsx
@@ -0,0 +1,250 @@
+import { useEffect, useRef, useState, startTransition } from "react";
+import {
+ defaultEmail,
+ Email,
+ renderEmailToHtml,
+} from "@courselit/email-editor";
+import { cn } from "@/lib/shadcn-utils";
+import { Skeleton } from "@/components/ui/skeleton";
+
+export default function TemplateEmailPreview({
+ content,
+ className,
+ minHeight = "420px",
+}: {
+ content: Email | null;
+ className?: string;
+ minHeight?: string;
+}) {
+ const [renderedHTML, setRenderedHTML] = useState(null);
+ const [isLoading, setIsLoading] = useState(!!content);
+ const [error, setError] = useState(null);
+ const wrapperRef = useRef(null);
+ const [wrapperWidth, setWrapperWidth] = useState(0);
+
+ useEffect(() => {
+ if (content) {
+ const normalizedEmail = normalizeEmailForPreview(content);
+
+ startTransition(() => {
+ setRenderedHTML(null);
+ setIsLoading(true);
+ setError(null);
+ });
+
+ renderEmailToHtml({
+ email: normalizedEmail,
+ })
+ .then((html) => {
+ startTransition(() => {
+ setRenderedHTML(html);
+ setIsLoading(false);
+ });
+ })
+ .catch((err) => {
+ startTransition(() => {
+ setError(err.message || "Failed to render email");
+ setIsLoading(false);
+ });
+ });
+ } else {
+ startTransition(() => {
+ setRenderedHTML(null);
+ setIsLoading(false);
+ setError(null);
+ });
+ }
+ }, [content]);
+
+ useEffect(() => {
+ if (!wrapperRef.current) {
+ return;
+ }
+
+ setWrapperWidth(wrapperRef.current.clientWidth);
+
+ const observer = new ResizeObserver((entries) => {
+ const entry = entries[0];
+ if (entry) {
+ setWrapperWidth(entry.contentRect.width);
+ }
+ });
+
+ observer.observe(wrapperRef.current);
+
+ return () => observer.disconnect();
+ }, [renderedHTML]);
+
+ if (!content) {
+ return null;
+ }
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return Error: {error}
;
+ }
+
+ if (!renderedHTML) {
+ return (
+
+ );
+ }
+
+ const normalizedEmail = normalizeEmailForPreview(content);
+ const previewHeight = toPixels(minHeight);
+ const previewWidth = getPreviewWidth(normalizedEmail);
+ const scale =
+ wrapperWidth > 0 ? Math.min(wrapperWidth / previewWidth, 1) : 1;
+ const previewViewportHeight =
+ scale > 0 ? previewHeight / scale : previewHeight;
+
+ return (
+
+ );
+}
+
+function getPreviewWidth(email: Email): number {
+ const width =
+ email.style?.structure?.page?.width ||
+ defaultEmail.style.structure.page.width;
+ const parsedWidth = Number.parseInt(width || "600px", 10);
+
+ return Number.isFinite(parsedWidth) ? parsedWidth : 600;
+}
+
+function toPixels(value: string): number {
+ const parsed = Number.parseInt(value, 10);
+
+ return Number.isFinite(parsed) ? parsed : 420;
+}
+
+function normalizeEmailForPreview(
+ content: Email,
+ { responsivePageWidth = false }: { responsivePageWidth?: boolean } = {},
+): Email {
+ const legacyStyle = (content as any)?.style || {};
+ const defaultStyle = defaultEmail.style;
+
+ return {
+ ...defaultEmail,
+ ...content,
+ meta: {
+ ...defaultEmail.meta,
+ ...(content.meta || {}),
+ },
+ style: {
+ ...defaultStyle,
+ ...(content.style || {}),
+ colors: {
+ ...defaultStyle.colors,
+ ...(content.style?.colors || {}),
+ background:
+ content.style?.colors?.background ||
+ legacyStyle.backgroundColor ||
+ defaultStyle.colors.background,
+ },
+ typography: {
+ ...defaultStyle.typography,
+ ...(content.style?.typography || {}),
+ header: {
+ ...defaultStyle.typography.header,
+ ...(content.style?.typography?.header || {}),
+ },
+ text: {
+ ...defaultStyle.typography.text,
+ ...(content.style?.typography?.text || {}),
+ },
+ link: {
+ ...defaultStyle.typography.link,
+ ...(content.style?.typography?.link || {}),
+ },
+ },
+ interactives: {
+ ...defaultStyle.interactives,
+ ...(content.style?.interactives || {}),
+ button: {
+ ...defaultStyle.interactives.button,
+ ...(content.style?.interactives?.button || {}),
+ padding: {
+ ...defaultStyle.interactives.button.padding,
+ ...(content.style?.interactives?.button?.padding || {}),
+ },
+ border: {
+ ...defaultStyle.interactives.button.border,
+ ...(content.style?.interactives?.button?.border || {}),
+ },
+ },
+ link: {
+ ...defaultStyle.interactives.link,
+ ...(content.style?.interactives?.link || {}),
+ padding: {
+ ...defaultStyle.interactives.link.padding,
+ ...(content.style?.interactives?.link?.padding || {}),
+ },
+ },
+ },
+ structure: {
+ ...defaultStyle.structure,
+ ...(content.style?.structure || {}),
+ page: {
+ ...defaultStyle.structure.page,
+ ...(content.style?.structure?.page || {}),
+ background:
+ content.style?.structure?.page?.background ||
+ legacyStyle.backgroundColor ||
+ defaultStyle.structure.page.background,
+ width: responsivePageWidth
+ ? "100%"
+ : content.style?.structure?.page?.width ||
+ (typeof legacyStyle.width === "number"
+ ? `${legacyStyle.width}px`
+ : legacyStyle.width) ||
+ defaultStyle.structure.page.width,
+ marginY: responsivePageWidth
+ ? "0px"
+ : content.style?.structure?.page?.marginY ||
+ defaultStyle.structure.page.marginY,
+ },
+ section: {
+ ...defaultStyle.structure.section,
+ ...(content.style?.structure?.section || {}),
+ padding: {
+ ...defaultStyle.structure.section.padding,
+ ...(content.style?.structure?.section?.padding || {}),
+ },
+ },
+ },
+ },
+ content: content.content || defaultEmail.content,
+ };
+}
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/sequence/[id]/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/sequence/[id]/page.tsx
index d2883634c..51aa20dfa 100644
--- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/sequence/[id]/page.tsx
+++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/sequence/[id]/page.tsx
@@ -1,7 +1,6 @@
"use client";
import DashboardContent from "@components/admin/dashboard-content";
-import { AddressContext } from "@components/contexts";
import {
DELETE_EMAIL_DIALOG_HEADER,
PAGE_HEADER_EDIT_SEQUENCE,
@@ -10,7 +9,7 @@ import {
TOAST_TITLE_ERROR,
TOAST_TITLE_SUCCESS,
} from "@ui-config/strings";
-import { useContext, useState, useEffect, useCallback, use } from "react";
+import { useState, useEffect, useCallback, use } from "react";
import { Tabbs, useToast } from "@courselit/components-library";
import EmailAnalytics from "@components/admin/mails/email-analytics";
import { useSequence } from "@/hooks/use-sequence";
@@ -29,6 +28,7 @@ import {
Badge,
} from "@courselit/components-library";
import { Community, Course } from "@courselit/common-models";
+import { useRouter } from "next/navigation";
import {
COMPOSE_SEQUENCE_ENTRANCE_CONDITION,
COMPOSE_SEQUENCE_ENTRANCE_CONDITION_DATA,
@@ -55,7 +55,7 @@ export default function Page(props: {
}>;
}) {
const params = use(props.params);
- const address = useContext(AddressContext);
+ const router = useRouter();
const { id } = params;
const { sequence, loading, loadSequence } = useSequence();
const [activeTab, setActiveTab] = useState("Compose");
@@ -182,35 +182,11 @@ export default function Page(props: {
}
}, [triggerType]);
- const addMailToSequence = useCallback(async () => {
- const query = `
- mutation AddMailToSequence($sequenceId: String!) {
- sequence: addMailToSequence(sequenceId: $sequenceId) {
- sequenceId,
- }
- }`;
-
- const fetcher = fetch
- .setPayload({ query, variables: { sequenceId: id } })
- .build();
-
- try {
- const response = await fetcher.exec();
- if (response.sequence) {
- await loadSequence(id);
- toast({
- title: TOAST_TITLE_SUCCESS,
- description: "New email added to sequence",
- });
- }
- } catch (e: any) {
- toast({
- title: TOAST_TITLE_ERROR,
- description: e.message,
- variant: "destructive",
- });
- }
- }, [fetch, id, loadSequence, toast]);
+ const addMailToSequence = useCallback(() => {
+ router.push(
+ `/dashboard/mails/new?type=sequence&sequenceId=${id}&mode=add-to-sequence`,
+ );
+ }, [id, router]);
const updateSequence = useCallback(async () => {
const query = `
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/template/[id]/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/template/[id]/page.tsx
new file mode 100644
index 000000000..bd5b2f2d4
--- /dev/null
+++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/template/[id]/page.tsx
@@ -0,0 +1,383 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState, use } from "react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import * as z from "zod";
+import DashboardContent from "@components/admin/dashboard-content";
+import EmailViewer from "@components/admin/mails/email-viewer";
+import { useGraphQLFetch } from "@/hooks/use-graphql-fetch";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@components/ui/alert-dialog";
+import { Button } from "@components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Separator } from "@/components/ui/separator";
+import { useToast } from "@courselit/components-library";
+import { EmailTemplate } from "@courselit/common-models";
+import { useRouter } from "next/navigation";
+import { truncate } from "@courselit/utils";
+import { Loader2, Save, Trash2 } from "lucide-react";
+import {
+ BTN_DELETE_TEMPLATE,
+ BUTTON_SAVING,
+ BUTTON_SAVE,
+ DANGER_ZONE_HEADER,
+ DELETE_TEMPLATE_DIALOG_DESCRIPTION,
+ DELETE_TEMPLATE_DIALOG_HEADER,
+ PAGE_HEADER_MANAGE,
+ TEMPLATES,
+ TEMPLATE_MANAGE_DESCRIPTION,
+ TEMPLATE_NAME_LABEL,
+ TEMPLATE_NAME_PLACEHOLDER,
+ TEMPLATE_PREVIEW_DESCRIPTION,
+ TEMPLATE_PREVIEW_HEADER,
+ TOAST_TEMPLATE_DELETED,
+ TOAST_TEMPLATE_SAVED,
+ TOAST_TITLE_ERROR,
+ TOAST_TITLE_SUCCESS,
+} from "@ui-config/strings";
+
+const formSchema = z.object({
+ title: z.string().trim().min(1, "Template name is required"),
+});
+
+type FormData = z.infer;
+
+export default function Page(props: {
+ params: Promise<{
+ id: string;
+ }>;
+}) {
+ const { id } = use(props.params);
+ const router = useRouter();
+ const { toast } = useToast();
+ const fetch = useGraphQLFetch();
+ const [template, setTemplate] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isSaving, setIsSaving] = useState(false);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const initialValuesRef = useRef({ title: "" });
+ const currentValuesRef = useRef({ title: "" });
+
+ const form = useForm({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ title: "",
+ },
+ mode: "onChange",
+ });
+
+ const watchedTitle = form.watch("title");
+
+ useEffect(() => {
+ currentValuesRef.current = { title: watchedTitle || "" };
+ }, [watchedTitle]);
+
+ const canSubmit = useMemo(() => {
+ const currentTitle = (watchedTitle || "").trim();
+
+ return (
+ currentTitle.length > 0 &&
+ currentTitle !== initialValuesRef.current.title &&
+ form.formState.isValid &&
+ !isLoading &&
+ !isSaving
+ );
+ }, [form.formState.isValid, isLoading, isSaving, watchedTitle]);
+
+ const loadTemplate = useCallback(async () => {
+ setIsLoading(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: id,
+ },
+ })
+ .build()
+ .exec();
+
+ if (response.template) {
+ setTemplate(response.template);
+ initialValuesRef.current = {
+ title: response.template.title.trim(),
+ };
+ currentValuesRef.current = {
+ title: response.template.title,
+ };
+ form.reset({
+ title: response.template.title,
+ });
+ }
+ } catch (err: any) {
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description: err.message,
+ variant: "destructive",
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ }, [fetch, form, id, toast]);
+
+ useEffect(() => {
+ loadTemplate();
+ }, [loadTemplate]);
+
+ const onSubmit = async () => {
+ const title = currentValuesRef.current.title.trim();
+
+ if (!title || title === initialValuesRef.current.title) {
+ return;
+ }
+
+ const mutation = `
+ mutation UpdateEmailTemplate($templateId: String!, $title: String!) {
+ template: updateEmailTemplate(
+ templateId: $templateId
+ title: $title
+ ) {
+ templateId
+ title
+ content {
+ content {
+ blockType
+ settings
+ }
+ style
+ meta
+ }
+ }
+ }
+ `;
+
+ try {
+ setIsSaving(true);
+
+ const response = await fetch
+ .setPayload({
+ query: mutation,
+ variables: {
+ templateId: id,
+ title,
+ },
+ })
+ .build()
+ .exec();
+
+ if (response.template) {
+ setTemplate(response.template);
+ initialValuesRef.current = {
+ title: response.template.title.trim(),
+ };
+ currentValuesRef.current = { title: response.template.title };
+ form.reset({ title: response.template.title });
+ toast({
+ title: TOAST_TITLE_SUCCESS,
+ description: TOAST_TEMPLATE_SAVED,
+ });
+ }
+ } catch (err: any) {
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description: err.message,
+ variant: "destructive",
+ });
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ const deleteTemplate = async () => {
+ const mutation = `
+ mutation DeleteEmailTemplate($templateId: String!) {
+ deleted: deleteEmailTemplate(templateId: $templateId)
+ }
+ `;
+
+ try {
+ setIsDeleting(true);
+
+ const response = await fetch
+ .setPayload({
+ query: mutation,
+ variables: {
+ templateId: id,
+ },
+ })
+ .build()
+ .exec();
+
+ if (response.deleted) {
+ toast({
+ title: TOAST_TITLE_SUCCESS,
+ description: TOAST_TEMPLATE_DELETED,
+ });
+ router.replace("/dashboard/mails?tab=Templates");
+ }
+ } catch (err: any) {
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description: err.message,
+ variant: "destructive",
+ });
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ const breadcrumbs = [
+ { label: TEMPLATES, href: "/dashboard/mails?tab=Templates" },
+ {
+ label: truncate(template?.title || PAGE_HEADER_MANAGE, 20),
+ href: "#",
+ },
+ ];
+
+ return (
+
+
+
+
+ {PAGE_HEADER_MANAGE}
+
+
+ {TEMPLATE_MANAGE_DESCRIPTION}
+
+
+
+
+
+
+
+
+
+ {TEMPLATE_PREVIEW_DESCRIPTION}
+
+
+
+ {!isLoading && (
+
+ )}
+
+
+
+ {DANGER_ZONE_HEADER}
+
+
+
+
+
+
+
+
+ {DELETE_TEMPLATE_DIALOG_HEADER}
+
+
+ {DELETE_TEMPLATE_DIALOG_DESCRIPTION}
+
+
+
+ Cancel
+
+ {BTN_DELETE_TEMPLATE}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(with-contexts)/dashboard/mail/layout.tsx b/apps/web/app/(with-contexts)/dashboard/mail/layout.tsx
index 0062940d7..c7fe17283 100644
--- a/apps/web/app/(with-contexts)/dashboard/mail/layout.tsx
+++ b/apps/web/app/(with-contexts)/dashboard/mail/layout.tsx
@@ -10,7 +10,7 @@ export async function generateMetadata(
};
}
-export default function DripEmailEditorLayout({
+export default function EmailLayout({
children,
}: {
children: React.ReactNode;
diff --git a/apps/web/app/(with-contexts)/dashboard/mail/sequence/[sequenceId]/[mailId]/page.tsx b/apps/web/app/(with-contexts)/dashboard/mail/sequence/[sequenceId]/[mailId]/page.tsx
index 279ae8a63..1b9d046a6 100644
--- a/apps/web/app/(with-contexts)/dashboard/mail/sequence/[sequenceId]/[mailId]/page.tsx
+++ b/apps/web/app/(with-contexts)/dashboard/mail/sequence/[sequenceId]/[mailId]/page.tsx
@@ -11,6 +11,7 @@ import {
} from "@/components/ui/tooltip";
import { LogOut } from "lucide-react";
import Link from "next/link";
+import { EMAIL_EDITOR_EMAIL_EDIT_HEADER } from "@ui-config/strings";
export default function EmailEditorPage(props: {
params: Promise<{
@@ -43,7 +44,7 @@ const EditorLayout = ({
- Email Editor
+ {EMAIL_EDITOR_EMAIL_EDIT_HEADER}
diff --git a/apps/web/app/(with-contexts)/dashboard/mails/template/[id]/internal/page.tsx b/apps/web/app/(with-contexts)/dashboard/mail/template/[id]/internal/page.tsx
similarity index 85%
rename from apps/web/app/(with-contexts)/dashboard/mails/template/[id]/internal/page.tsx
rename to apps/web/app/(with-contexts)/dashboard/mail/template/[id]/internal/page.tsx
index 408f832dd..37f8a497d 100644
--- a/apps/web/app/(with-contexts)/dashboard/mails/template/[id]/internal/page.tsx
+++ b/apps/web/app/(with-contexts)/dashboard/mail/template/[id]/internal/page.tsx
@@ -3,7 +3,7 @@
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 { useState, useEffect, useCallback, useRef, useMemo, use } from "react";
import type { Email as EmailContent } from "@courselit/email-editor";
import { useToast } from "@courselit/components-library";
import { debounce } from "@courselit/utils";
@@ -14,10 +14,11 @@ import { EmailTemplate } from "@courselit/common-models";
export default function EmailTemplateEditorPage({
params,
}: {
- params: {
+ params: Promise<{
id: string;
- };
+ }>;
}) {
+ const { id } = use(params);
const [email, setEmail] = useState(null);
const [isSaving, setIsSaving] = useState(false);
const { toast } = useToast();
@@ -55,7 +56,7 @@ export default function EmailTemplateEditorPage({
.setPayload({
query,
variables: {
- templateId: params.id,
+ templateId: id,
},
})
.build()
@@ -73,7 +74,7 @@ export default function EmailTemplateEditorPage({
} finally {
setLoading(false);
}
- }, [params.id, fetch]);
+ }, [id, fetch]);
useEffect(() => {
loadTemplate();
@@ -92,32 +93,32 @@ export default function EmailTemplateEditorPage({
setIsSaving(true);
const mutation = `
- mutation UpdateEmailTemplate(
- $templateId: String!,
- $content: String,
- ) {
- template: updateEmailTemplate(
- templateId: $templateId,
- content: $content,
+ mutation UpdateEmailTemplate(
+ $templateId: String!,
+ $content: String,
) {
- templateId
- title
- content {
+ template: updateEmailTemplate(
+ templateId: $templateId,
+ content: $content,
+ ) {
+ templateId
+ title
content {
- blockType
- settings
+ content {
+ blockType
+ settings
+ }
+ style
+ meta
}
- style
- meta
}
- }
- }`;
+ }`;
const fetcher = fetch
.setPayload({
query: mutation,
variables: {
- templateId: params.id,
+ templateId: id,
content: JSON.stringify(emailContent),
},
})
@@ -139,7 +140,7 @@ export default function EmailTemplateEditorPage({
setIsSaving(false);
}
},
- [params.id, fetch, toast],
+ [id, fetch, toast],
);
const debouncedSave = useMemo(() => debounce(saveEmail, 1000), [saveEmail]);
diff --git a/apps/web/app/(with-contexts)/dashboard/mails/template/[id]/page.tsx b/apps/web/app/(with-contexts)/dashboard/mail/template/[id]/page.tsx
similarity index 86%
rename from apps/web/app/(with-contexts)/dashboard/mails/template/[id]/page.tsx
rename to apps/web/app/(with-contexts)/dashboard/mail/template/[id]/page.tsx
index 0b59fd5a3..107d46db1 100644
--- a/apps/web/app/(with-contexts)/dashboard/mails/template/[id]/page.tsx
+++ b/apps/web/app/(with-contexts)/dashboard/mail/template/[id]/page.tsx
@@ -2,6 +2,7 @@
import { useSearchParams } from "next/navigation";
import { Button2 } from "@courselit/components-library";
+import { use } from "react";
import {
Tooltip,
TooltipContent,
@@ -10,24 +11,27 @@ import {
} from "@/components/ui/tooltip";
import { LogOut } from "lucide-react";
import Link from "next/link";
+import { EMAIL_EDITOR_TEMPLATE_EDIT_HEADER } from "@ui-config/strings";
export default function EmailTemplateEditorPage({
params,
}: {
- params: {
+ params: Promise<{
id: string;
- };
+ }>;
}) {
+ const { id } = use(params);
const searchParams = useSearchParams();
const redirectTo = searchParams?.get("redirectTo");
return (
);
}
+
const EditorLayout = ({
src,
redirectTo,
@@ -42,7 +46,7 @@ const EditorLayout = ({
- Template Editor
+ {EMAIL_EDITOR_TEMPLATE_EDIT_HEADER}
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
deleted file mode 100644
index 91b398a46..000000000
--- a/apps/web/app/(with-contexts)/dashboard/mails/new/new-mail-page-client.tsx
+++ /dev/null
@@ -1,160 +0,0 @@
-"use client";
-
-import { EmailTemplate, SequenceType } from "@courselit/common-models";
-import { useToast } from "@courselit/components-library";
-import { 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
deleted file mode 100644
index 0d721bbed..000000000
--- a/apps/web/app/(with-contexts)/dashboard/mails/new/page.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-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/components/admin/dashboard-skeleton/app-sidebar.tsx b/apps/web/components/admin/dashboard-skeleton/app-sidebar.tsx
index 3cfe17a2c..d76b162d2 100644
--- a/apps/web/components/admin/dashboard-skeleton/app-sidebar.tsx
+++ b/apps/web/components/admin/dashboard-skeleton/app-sidebar.tsx
@@ -32,8 +32,10 @@ import { ProfileContext, SiteInfoContext } from "@components/contexts";
import { checkPermission } from "@courselit/utils";
import { Profile, UIConstants } from "@courselit/common-models";
import {
+ BROADCASTS,
GET_SET_UP,
MY_CONTENT_HEADER,
+ SEQUENCES,
SIDEBAR_MENU_BLOGS,
SIDEBAR_MENU_MAILS,
SIDEBAR_MENU_PAGES,
@@ -44,6 +46,7 @@ import {
SITE_SETTINGS_SECTION_GENERAL,
SITE_SETTINGS_SECTION_MAILS,
SITE_SETTINGS_SECTION_PAYMENT,
+ TEMPLATES,
} from "@ui-config/strings";
import { NavSecondary } from "./nav-secondary";
import { usePathname, useSearchParams } from "next/navigation";
@@ -227,18 +230,25 @@ function getSidebarItems({
path?.startsWith("/dashboard/mail"),
items: [
{
- title: "Broadcasts",
- url: "/dashboard/mails?tab=Broadcasts",
+ title: BROADCASTS,
+ url: `/dashboard/mails?tab=${BROADCASTS}`,
isActive:
`${path}?tab=${tab}` ===
- "/dashboard/mails?tab=Broadcasts",
+ `/dashboard/mails?tab=${BROADCASTS}`,
},
{
- title: "Sequences",
- url: "/dashboard/mails?tab=Sequences",
+ title: SEQUENCES,
+ url: `/dashboard/mails?tab=${SEQUENCES}`,
isActive:
`${path}?tab=${tab}` ===
- "/dashboard/mails?tab=Sequences",
+ `/dashboard/mails?tab=${SEQUENCES}`,
+ },
+ {
+ title: TEMPLATES,
+ url: `/dashboard/mails?tab=${TEMPLATES}`,
+ isActive:
+ `${path}?tab=${tab}` ===
+ `/dashboard/mails?tab=${TEMPLATES}`,
},
],
});
diff --git a/apps/web/components/admin/empty-state.tsx b/apps/web/components/admin/empty-state.tsx
new file mode 100644
index 000000000..0ac4b79f8
--- /dev/null
+++ b/apps/web/components/admin/empty-state.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import Link from "next/link";
+import { FileText } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/shadcn-utils";
+
+interface AdminEmptyStateProps {
+ title: string;
+ description: string;
+ actionLabel?: string;
+ actionHref?: string;
+ className?: string;
+}
+
+export default function AdminEmptyState({
+ title,
+ description,
+ actionLabel,
+ actionHref,
+ className,
+}: AdminEmptyStateProps) {
+ return (
+
+
+
+
+
+
{title}
+
{description}
+ {actionLabel && actionHref ? (
+
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/web/components/admin/mails/email-viewer.tsx b/apps/web/components/admin/mails/email-viewer.tsx
index 80c85b950..97a4f3b2e 100644
--- a/apps/web/components/admin/mails/email-viewer.tsx
+++ b/apps/web/components/admin/mails/email-viewer.tsx
@@ -17,6 +17,7 @@ export default function EmailViewer({
useEffect(() => {
if (content) {
startTransition(() => {
+ setRenderedHTML(null);
setIsLoading(true);
setError(null);
});
diff --git a/apps/web/components/admin/mails/index.tsx b/apps/web/components/admin/mails/index.tsx
index e65cd0cbf..3b0e20a14 100644
--- a/apps/web/components/admin/mails/index.tsx
+++ b/apps/web/components/admin/mails/index.tsx
@@ -86,47 +86,15 @@ export default function Mails({ address, selectedTab }: MailsProps) {
getSiteInfo();
}, []);
- 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 {
- 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",
- });
- }
- };
-
const onPrimaryButtonClick = (): void => {
if (selectedTab === BROADCASTS) {
- router.push(`/dashboard/mails/new?type=broadcast`);
+ router.push(
+ `/dashboard/mails/new?type=broadcast&source=broadcasts`,
+ );
} else if (selectedTab === SEQUENCES) {
- router.push(`/dashboard/mails/new?type=sequence`);
+ router.push(`/dashboard/mails/new?type=sequence&source=sequences`);
} else {
- createEmailTemplate();
+ router.push(`/dashboard/mails/new?type=template&source=templates`);
}
};
@@ -206,14 +174,8 @@ export default function Mails({ address, selectedTab }: MailsProps) {
router.replace(`/dashboard/mails?tab=${tab}`);
}}
>
-
-
+
+
diff --git a/apps/web/components/admin/mails/templates-list.tsx b/apps/web/components/admin/mails/templates-list.tsx
index c69d03415..5bb028ecd 100644
--- a/apps/web/components/admin/mails/templates-list.tsx
+++ b/apps/web/components/admin/mails/templates-list.tsx
@@ -2,10 +2,14 @@
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 {
+ TOAST_TITLE_ERROR,
+ MAIL_TABLE_HEADER_TITLE,
+ TEMPLATES_EMPTY_STATE_CTA,
+ TEMPLATES_EMPTY_STATE_DESCRIPTION,
+ TEMPLATES_EMPTY_STATE_TITLE,
+} from "@ui-config/strings";
import { useEffect, useState } from "react";
import {
Table,
@@ -16,14 +20,13 @@ import {
TableRow,
} from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
+import AdminEmptyState from "@components/admin/empty-state";
interface TemplatesListProps {
address: Address;
- loading: boolean;
- dispatch?: AppDispatch;
}
-const TemplatesList = ({ address, dispatch, loading }: TemplatesListProps) => {
+const TemplatesList = ({ address }: TemplatesListProps) => {
const [templates, setTemplates] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
@@ -53,7 +56,6 @@ const TemplatesList = ({ address, dispatch, loading }: TemplatesListProps) => {
.build();
try {
- dispatch && dispatch(networkAction(true));
const response = await fetcher.exec();
if (response.templates) {
setTemplates(response.templates);
@@ -65,42 +67,51 @@ const TemplatesList = ({ address, dispatch, loading }: TemplatesListProps) => {
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}
-
-
-
- ))}
-
-
+ {!isLoading && templates.length === 0 ? (
+
+ ) : (
+
+
+
+ {MAIL_TABLE_HEADER_TITLE}
+
+
+
+ {isLoading
+ ? Array.from({ length: 10 }).map((_, idx) => (
+
+
+
+
+
+ ))
+ : templates.map((template) => (
+
+
+
+ {template.title}
+
+
+
+ ))}
+
+
+ )}
);
};
diff --git a/apps/web/config/strings.ts b/apps/web/config/strings.ts
index 12be83b4b..156c751ef 100644
--- a/apps/web/config/strings.ts
+++ b/apps/web/config/strings.ts
@@ -98,6 +98,7 @@ export const responses = {
"Email delivery failed for all recipients",
courses_cannot_be_downloaded: "A course cannot be offered as a download.",
apikey_already_exists: "Apikey with that name already exists",
+ email_template_already_exists: "A template with that name already exists",
sequence_details_missing: "The following settings are missing",
invalid_emails_order: "Invalid emails order",
no_published_emails: "No published emails",
diff --git a/apps/web/graphql/mails/__tests__/logic.test.ts b/apps/web/graphql/mails/__tests__/logic.test.ts
new file mode 100644
index 000000000..f9bb4fd47
--- /dev/null
+++ b/apps/web/graphql/mails/__tests__/logic.test.ts
@@ -0,0 +1,251 @@
+/**
+ * @jest-environment node
+ */
+
+import DomainModel from "@/models/Domain";
+import EmailTemplateModel from "@/models/EmailTemplate";
+import constants from "@/config/constants";
+import { responses } from "@/config/strings";
+import GQLContext from "@/models/GQLContext";
+import { defaultEmail } from "../default-email";
+import {
+ addMailToSequence,
+ createEmailTemplate,
+ createSequence,
+ getEmailTemplates,
+ getSystemEmailTemplates,
+ updateEmailTemplate,
+} from "../logic";
+import SequenceModel from "@/models/Sequence";
+
+const { permissions } = constants;
+
+describe("createEmailTemplate", () => {
+ let domain: any;
+ let ctx: GQLContext;
+
+ beforeAll(async () => {
+ domain = await DomainModel.create({
+ name: `mail-template-domain-${Date.now()}-${Math.floor(Math.random() * 100000)}`,
+ email: "owner@example.com",
+ });
+ });
+
+ beforeEach(async () => {
+ ctx = {
+ subdomain: domain,
+ user: {
+ userId: "admin-user",
+ permissions: [permissions.manageUsers],
+ },
+ address: "https://example.com",
+ } as unknown as GQLContext;
+
+ await EmailTemplateModel.deleteMany({ domain: domain._id });
+ await SequenceModel.deleteMany({ domain: domain._id });
+ });
+
+ afterAll(async () => {
+ await EmailTemplateModel.deleteMany({ domain: domain._id });
+ await SequenceModel.deleteMany({ domain: domain._id });
+ await DomainModel.deleteMany({ _id: domain._id });
+ });
+
+ it("creates a unique title when the default title already exists", async () => {
+ await EmailTemplateModel.create({
+ domain: domain._id,
+ templateId: "template-1",
+ title: "Blank",
+ creatorId: "existing-user",
+ content: defaultEmail,
+ });
+
+ const template = await createEmailTemplate({
+ templateId: "system-5",
+ context: ctx,
+ });
+
+ expect(template.title).toBe("Blank 2");
+ });
+
+ it("fills the next available numeric suffix", async () => {
+ await EmailTemplateModel.create([
+ {
+ domain: domain._id,
+ templateId: "template-1",
+ title: "Blank",
+ creatorId: "existing-user",
+ content: defaultEmail,
+ },
+ {
+ domain: domain._id,
+ templateId: "template-2",
+ title: "Blank 2",
+ creatorId: "existing-user",
+ content: defaultEmail,
+ },
+ ]);
+
+ const template = await createEmailTemplate({
+ templateId: "system-5",
+ context: ctx,
+ });
+
+ expect(template.title).toBe("Blank 3");
+ });
+
+ it("creates a template from selected starter content", async () => {
+ await EmailTemplateModel.create({
+ domain: domain._id,
+ templateId: "template-starter-source",
+ title: "Starter source",
+ creatorId: "existing-user",
+ content: defaultEmail,
+ });
+
+ const template = await createEmailTemplate({
+ templateId: "template-starter-source",
+ context: ctx,
+ });
+
+ expect(template.title).toBe("Starter source 2");
+ expect(template.content?.meta?.previewText).toBe(
+ defaultEmail.meta.previewText,
+ );
+ });
+
+ it("shows a friendly error when renaming a template to an existing title", async () => {
+ await EmailTemplateModel.create([
+ {
+ domain: domain._id,
+ templateId: "template-1",
+ title: "Welcome template",
+ creatorId: "existing-user",
+ content: defaultEmail,
+ },
+ {
+ domain: domain._id,
+ templateId: "template-2",
+ title: "Follow up template",
+ creatorId: "existing-user",
+ content: defaultEmail,
+ },
+ ]);
+
+ await expect(
+ updateEmailTemplate({
+ templateId: "template-2",
+ title: "Welcome template",
+ context: ctx,
+ }),
+ ).rejects.toThrow(responses.email_template_already_exists);
+ });
+
+ it("adds a new sequence email from a selected template id", async () => {
+ await SequenceModel.create({
+ domain: domain._id,
+ sequenceId: "sequence-1",
+ title: "Welcome flow",
+ type: "sequence",
+ from: {
+ name: "Admin",
+ email: "admin@example.com",
+ },
+ trigger: {
+ type: "subscriber:added",
+ },
+ emails: [],
+ emailsOrder: [],
+ creatorId: "admin-user",
+ });
+
+ await EmailTemplateModel.create({
+ domain: domain._id,
+ templateId: "template-launch-1",
+ title: "Launch email",
+ creatorId: "admin-user",
+ content: defaultEmail,
+ });
+
+ const sequence = await addMailToSequence(
+ ctx,
+ "sequence-1",
+ "template-launch-1",
+ );
+
+ expect(sequence?.emails).toHaveLength(1);
+ expect(sequence?.emails[0].subject).toBe("Launch email");
+ expect(sequence?.emails[0].content?.meta?.previewText).toBe(
+ defaultEmail.meta.previewText,
+ );
+ });
+
+ it("returns templates in reverse chronological order", async () => {
+ await EmailTemplateModel.create({
+ domain: domain._id,
+ templateId: "template-1",
+ title: "Older template",
+ creatorId: "existing-user",
+ content: defaultEmail,
+ });
+
+ await EmailTemplateModel.create({
+ domain: domain._id,
+ templateId: "template-2",
+ title: "Newer template",
+ creatorId: "existing-user",
+ content: defaultEmail,
+ });
+
+ const templates = await getEmailTemplates({ context: ctx });
+
+ expect(templates.map((template) => template.title)).toEqual([
+ "Newer template",
+ "Older template",
+ ]);
+ });
+
+ it("returns discovered system email templates", async () => {
+ const templates = await getSystemEmailTemplates({ context: ctx });
+
+ expect(templates.length).toBeGreaterThan(0);
+ expect(
+ templates.some((template) => template.templateId === "system-5"),
+ ).toBe(true);
+ });
+
+ it("creates a sequence from a system template id", async () => {
+ const sequence = await createSequence(ctx, "sequence", "system-5");
+
+ expect(sequence?.title).toBe("Blank");
+ expect(sequence?.emails[0].subject).toBe("Blank");
+ expect(sequence?.emails[0].content).toMatchObject({
+ meta: {
+ previewText:
+ "A blank starter template with only content and unsubscribe placeholders.",
+ },
+ });
+ });
+
+ it("creates a sequence from a saved custom template id", async () => {
+ await EmailTemplateModel.create({
+ domain: domain._id,
+ templateId: "template-custom-1",
+ title: "Custom welcome",
+ creatorId: "admin-user",
+ content: defaultEmail,
+ });
+
+ const sequence = await createSequence(
+ ctx,
+ "broadcast",
+ "template-custom-1",
+ );
+
+ expect(sequence?.title).toBe("Custom welcome");
+ expect(sequence?.emails[0].subject).toBe("Custom welcome");
+ expect(sequence?.emails[0].content?.meta?.previewText).toBe(
+ defaultEmail.meta.previewText,
+ );
+ });
+});
diff --git a/apps/web/graphql/mails/logic.ts b/apps/web/graphql/mails/logic.ts
index c342e4fce..39782cd5b 100644
--- a/apps/web/graphql/mails/logic.ts
+++ b/apps/web/graphql/mails/logic.ts
@@ -1,6 +1,8 @@
import constants from "@config/constants";
+import { promises as fs } from "fs";
import GQLContext from "@models/GQLContext";
import UserModel from "@models/User";
+import path from "path";
import { error } from "../../services/logger";
import { createUser, getMembership } from "../users/logic";
import {
@@ -29,15 +31,91 @@ import { AdminSequence, InternalCourse } from "@courselit/orm-models";
import { User } from "@courselit/common-models";
import EmailDeliveryModel from "@models/EmailDelivery";
import EmailEventModel from "@models/EmailEvent";
-import EmailTemplate from "@models/EmailTemplate";
+import EmailTemplateModel from "@models/EmailTemplate";
import { defaultEmail } from "./default-email";
import { sanitizeEmail } from "@/lib/sanitize-email";
+import { isDuplicateKeyError } from "../pages/helpers";
const { permissions } = constants;
const isDateInFuture = (timestamp?: number) =>
typeof timestamp === "number" && timestamp > Date.now();
+type EmailTemplateContent = typeof defaultEmail;
+
+type SystemEmailTemplate = {
+ templateId: string;
+ title: string;
+ content: EmailTemplateContent;
+};
+
+async function getSystemTemplateEntries(): Promise {
+ const templatesDir = await getSystemTemplatesDir();
+ const realTemplatesDir = await fs.realpath(templatesDir);
+ const filenames = await fs.readdir(templatesDir);
+
+ const templates = filenames
+ .filter((filename) => path.extname(filename) === ".json")
+ .map(async (filename) => {
+ const filePath = path.join(templatesDir, filename);
+ const stats = await fs.lstat(filePath);
+ if (!stats.isFile() || stats.isSymbolicLink()) {
+ throw new Error(`Invalid template file: ${filename}`);
+ }
+
+ const realFilePath = await fs.realpath(filePath);
+ if (
+ realFilePath !== path.join(realTemplatesDir, filename) ||
+ !realFilePath.startsWith(`${realTemplatesDir}${path.sep}`)
+ ) {
+ throw new Error(`Template path escapes directory: ${filename}`);
+ }
+
+ const fileContents = await fs.readFile(realFilePath, "utf8");
+ return JSON.parse(fileContents) as SystemEmailTemplate;
+ });
+
+ return Promise.all(templates);
+}
+
+async function getSystemTemplatesDir() {
+ const candidates = [
+ path.join(process.cwd(), "templates/system-emails"),
+ path.join(process.cwd(), "apps/web/templates/system-emails"),
+ ];
+
+ for (const candidate of candidates) {
+ try {
+ await fs.access(candidate);
+ return candidate;
+ } catch {}
+ }
+
+ throw new Error("System email templates directory not found");
+}
+
+async function getTemplate({
+ templateId,
+ ctx,
+}: {
+ templateId: string;
+ ctx: GQLContext;
+}) {
+ const systemTemplates = await getSystemTemplateEntries();
+ const systemTemplate = systemTemplates.find(
+ (template) => template.templateId === templateId,
+ );
+
+ if (systemTemplate) {
+ return systemTemplate;
+ }
+
+ return EmailTemplateModel.findOne({
+ templateId,
+ domain: ctx.subdomain._id,
+ });
+}
+
export async function createSubscription(
name: string,
email: string,
@@ -96,8 +174,7 @@ const defaultEmailContent = {
export async function createSequence(
ctx: GQLContext,
type: (typeof Constants.mailTypes)[number],
- title?: string,
- content?: string,
+ templateId: string,
): Promise<(Sequence & { creatorId: string }) | null> {
checkIfAuthenticated(ctx);
@@ -106,21 +183,24 @@ export async function createSequence(
}
try {
+ const template = await getTemplate({ templateId, ctx });
+ if (!template) {
+ throw new Error(responses.item_not_found);
+ }
+
const emailId = generateUniqueId();
const sequenceObj: Partial = {
domain: ctx.subdomain._id,
type,
status: Constants.sequenceStatus[0],
- title: title || internal.default_email_sequence_name,
+ title: template.title || internal.default_email_sequence_name,
creatorId: ctx.user.userId,
emails: [
{
emailId,
- content: content
- ? JSON.parse(content)
- : defaultEmailContent,
+ content: template.content || defaultEmailContent,
subject:
- title ||
+ template.title ||
(type === "broadcast"
? internal.default_email_broadcast_subject
: internal.default_email_sequence_subject),
@@ -650,6 +730,7 @@ export async function deleteMailFromSequence({
export async function addMailToSequence(
ctx: GQLContext,
sequenceId: string,
+ templateId: string,
): Promise {
checkIfAuthenticated(ctx);
@@ -670,11 +751,10 @@ export async function addMailToSequence(
throw new Error(responses.action_not_allowed);
}
- // const lastEmail = sequence.emails.find(
- // (email) =>
- // email.emailId ===
- // sequence.emailsOrder[sequence.emailsOrder.length - 1],
- // );
+ const template = await getTemplate({ templateId, ctx });
+ if (!template) {
+ throw new Error(responses.item_not_found);
+ }
const emailId = generateUniqueId();
const oneDayInMillis = +(
@@ -682,8 +762,8 @@ export async function addMailToSequence(
);
const email = {
emailId,
- content: defaultEmailContent,
- subject: internal.default_email_sequence_subject,
+ content: template.content || defaultEmailContent,
+ subject: template.title || internal.default_email_sequence_subject,
delayInMillis: oneDayInMillis,
};
@@ -1024,10 +1104,10 @@ export async function getSubscribersCount({
}
export async function createEmailTemplate({
- title,
+ templateId,
context,
}: {
- title: string;
+ templateId: string;
context: GQLContext;
}) {
checkIfAuthenticated(context);
@@ -1036,18 +1116,76 @@ export async function createEmailTemplate({
throw new Error(responses.action_not_allowed);
}
- const template = new EmailTemplate({
+ const sourceTemplate = await getTemplate({
+ templateId,
+ ctx: context,
+ });
+ if (!sourceTemplate) {
+ throw new Error(responses.item_not_found);
+ }
+
+ const uniqueTitle = await getUniqueEmailTemplateTitle(
+ context.subdomain._id,
+ sourceTemplate.title,
+ );
+ const newTemplateId = generateUniqueId();
+
+ const template = new EmailTemplateModel({
domain: context.subdomain._id,
- title,
+ templateId: newTemplateId,
+ title: uniqueTitle,
creatorId: context.user.userId,
- content: defaultEmailContent,
+ content: sourceTemplate.content || defaultEmailContent,
});
- await template.save();
+ try {
+ await template.save();
+ } catch (err: any) {
+ if (isDuplicateKeyError(err)) {
+ throw new Error(responses.email_template_already_exists);
+ }
+
+ throw err;
+ }
return template;
}
+async function getUniqueEmailTemplateTitle(
+ domainId: string | { toString(): string },
+ baseTitle: string,
+) {
+ const existingTemplates = await EmailTemplateModel.find({
+ domain: domainId,
+ title: {
+ $regex: new RegExp(
+ `^${escapeRegExp(baseTitle)}(?:\\s(\\d+))?$`,
+ "i",
+ ),
+ },
+ })
+ .select("title")
+ .lean();
+
+ const titles = new Set(existingTemplates.map((template) => template.title));
+
+ if (!titles.has(baseTitle)) {
+ return baseTitle;
+ }
+
+ let suffix = 2;
+
+ while (titles.has(`${baseTitle} ${suffix}`)) {
+ suffix += 1;
+ }
+
+ return `${baseTitle} ${suffix}`;
+}
+
+function escapeRegExp(value: string) {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
export async function updateEmailTemplate({
templateId,
title,
@@ -1065,7 +1203,7 @@ export async function updateEmailTemplate({
throw new Error(responses.action_not_allowed);
}
- const template = await EmailTemplate.findOne({
+ const template = await EmailTemplateModel.findOne({
templateId,
domain: context.subdomain._id,
});
@@ -1075,6 +1213,16 @@ export async function updateEmailTemplate({
}
if (title) {
+ const existingTemplate = await EmailTemplateModel.findOne({
+ domain: context.subdomain._id,
+ title,
+ templateId: { $ne: templateId },
+ })
+ .select("_id")
+ .lean();
+ if (existingTemplate) {
+ throw new Error(responses.email_template_already_exists);
+ }
template.title = title;
}
@@ -1082,7 +1230,15 @@ export async function updateEmailTemplate({
template.content = JSON.parse(content);
}
- await template.save();
+ try {
+ await template.save();
+ } catch (err: any) {
+ if (isDuplicateKeyError(err)) {
+ throw new Error(responses.email_template_already_exists);
+ }
+
+ throw err;
+ }
return template;
}
@@ -1100,7 +1256,7 @@ export async function deleteEmailTemplate({
throw new Error(responses.action_not_allowed);
}
- await EmailTemplate.deleteOne({
+ await EmailTemplateModel.deleteOne({
templateId,
domain: context.subdomain._id,
});
@@ -1121,7 +1277,7 @@ export async function getEmailTemplate({
throw new Error(responses.action_not_allowed);
}
- const template = await EmailTemplate.findOne({
+ const template = await EmailTemplateModel.findOne({
templateId,
domain: context.subdomain._id,
});
@@ -1136,9 +1292,25 @@ export async function getEmailTemplates({ context }: { context: GQLContext }) {
throw new Error(responses.action_not_allowed);
}
- const templates = await EmailTemplate.find({
+ const templates = await EmailTemplateModel.find({
domain: context.subdomain._id,
- });
+ }).sort({ createdAt: -1, _id: -1 });
+
+ return templates;
+}
+
+export async function getSystemEmailTemplates({
+ context,
+}: {
+ context: GQLContext;
+}) {
+ checkIfAuthenticated(context);
+
+ if (!checkPermission(context.user.permissions, [permissions.manageUsers])) {
+ throw new Error(responses.action_not_allowed);
+ }
+
+ const templates = await getSystemTemplateEntries();
return templates;
}
diff --git a/apps/web/graphql/mails/mutation.ts b/apps/web/graphql/mails/mutation.ts
index 011696a44..36d4436c5 100644
--- a/apps/web/graphql/mails/mutation.ts
+++ b/apps/web/graphql/mails/mutation.ts
@@ -48,33 +48,37 @@ const mutations = {
type: types.sequence,
args: {
type: { type: new GraphQLNonNull(types.sequenceType) },
- title: { type: GraphQLString },
- content: { type: GraphQLString },
+ templateId: { type: new GraphQLNonNull(GraphQLString) },
},
resolve: async (
_: any,
{
type,
- title,
- content,
+ templateId,
}: {
type: (typeof Constants.mailTypes)[number];
- title?: string;
- content?: string;
+ templateId: string;
},
context: GQLContext,
- ) => createSequence(context, type, title, content),
+ ) => createSequence(context, type, templateId),
},
addMailToSequence: {
type: types.sequence,
args: {
sequenceId: { type: new GraphQLNonNull(GraphQLString) },
+ templateId: { type: new GraphQLNonNull(GraphQLString) },
},
resolve: async (
_: any,
- { sequenceId }: { sequenceId: string },
+ {
+ sequenceId,
+ templateId,
+ }: {
+ sequenceId: string;
+ templateId: string;
+ },
context: GQLContext,
- ) => addMailToSequence(context, sequenceId),
+ ) => addMailToSequence(context, sequenceId, templateId),
},
deleteMailFromSequence: {
type: types.sequence,
@@ -339,13 +343,13 @@ const mutations = {
createEmailTemplate: {
type: types.emailTemplate,
args: {
- title: { type: new GraphQLNonNull(GraphQLString) },
+ templateId: { type: new GraphQLNonNull(GraphQLString) },
},
resolve: async (
_: any,
- { title }: { title: string },
+ { templateId }: { templateId: string },
context: GQLContext,
- ) => createEmailTemplate({ title, context }),
+ ) => createEmailTemplate({ templateId, context }),
},
updateEmailTemplate: {
diff --git a/apps/web/graphql/mails/query.ts b/apps/web/graphql/mails/query.ts
index 4eec2198a..a16a4292b 100644
--- a/apps/web/graphql/mails/query.ts
+++ b/apps/web/graphql/mails/query.ts
@@ -19,6 +19,7 @@ import {
getSubscribersCount,
getEmailTemplate,
getEmailTemplates,
+ getSystemEmailTemplates,
} from "./logic";
import GQLContext from "../../models/GQLContext";
import { SequenceType } from "@courselit/common-models";
@@ -151,7 +152,6 @@ const queries = {
context: GQLContext,
) => getSubscribersCount({ ctx: context, sequenceId }),
},
-
getEmailTemplate: {
type: types.emailTemplate,
args: {
@@ -163,12 +163,16 @@ const queries = {
context: GQLContext,
) => getEmailTemplate({ templateId, context }),
},
-
getEmailTemplates: {
type: new GraphQLList(types.emailTemplate),
resolve: (_: any, {}: {}, context: GQLContext) =>
getEmailTemplates({ context }),
},
+ getSystemEmailTemplates: {
+ type: new GraphQLList(types.emailTemplate),
+ resolve: (_: any, {}: {}, context: GQLContext) =>
+ getSystemEmailTemplates({ context }),
+ },
};
export default queries;
diff --git a/apps/web/templates/system-emails/announcement.json b/apps/web/templates/system-emails/announcement.json
new file mode 100644
index 000000000..1669532c1
--- /dev/null
+++ b/apps/web/templates/system-emails/announcement.json
@@ -0,0 +1,207 @@
+{
+ "templateId": "system-1",
+ "title": "Announcement",
+ "content": {
+ "style": {
+ "colors": {
+ "background": "#fdf2f8",
+ "foreground": "#111827",
+ "border": "#fbcfe8",
+ "accent": "#db2777",
+ "accentForeground": "#ffffff"
+ },
+ "typography": {
+ "header": {
+ "fontFamily": "Helvetica, sans-serif",
+ "letterSpacing": "-0.4px",
+ "textTransform": "none",
+ "textDecoration": "none"
+ },
+ "text": {
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "16px",
+ "lineHeight": "1.7",
+ "letterSpacing": "0px",
+ "textTransform": "none",
+ "textDecoration": "none"
+ },
+ "link": {
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "14px",
+ "lineHeight": "1.5",
+ "letterSpacing": "0.3px",
+ "textTransform": "uppercase",
+ "textDecoration": "none"
+ }
+ },
+ "interactives": {
+ "button": {
+ "padding": {
+ "x": "22px",
+ "y": "12px"
+ },
+ "border": {
+ "width": "0px",
+ "radius": "999px",
+ "style": "solid"
+ }
+ },
+ "link": {
+ "padding": {
+ "x": "0px",
+ "y": "0px"
+ }
+ }
+ },
+ "structure": {
+ "page": {
+ "background": "#ffffff",
+ "foreground": "#111827",
+ "width": "640px",
+ "marginY": "24px",
+ "borderWidth": "1px",
+ "borderStyle": "solid",
+ "borderRadius": "24px"
+ },
+ "section": {
+ "padding": {
+ "x": "32px",
+ "y": "20px"
+ }
+ }
+ }
+ },
+ "meta": {
+ "previewText": "A polished announcement template for launches, updates, and key news."
+ },
+ "content": [
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "JUST ANNOUNCED",
+ "alignment": "center",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "12px",
+ "foregroundColor": "#db2777",
+ "paddingTop": "6px",
+ "paddingBottom": "4px"
+ }
+ },
+ {
+ "blockType": "image",
+ "settings": {
+ "src": "https://images.pexels.com/photos/3184291/pexels-photo-3184291.jpeg?auto=compress&cs=tinysrgb&dpr=2&w=1200",
+ "alt": "Team collaborating in an office",
+ "alignment": "center",
+ "width": "100%",
+ "maxWidth": "100%",
+ "paddingTop": "8px",
+ "paddingBottom": "12px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "# Make your next announcement feel premium",
+ "alignment": "left",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "26px",
+ "foregroundColor": "#111827",
+ "paddingTop": "8px",
+ "paddingBottom": "10px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "This layout is designed for launches, waitlist openings, seasonal updates, and event drops where the headline and CTA need to carry the message clearly.",
+ "alignment": "left",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "17px",
+ "lineHeight": "1.7",
+ "foregroundColor": "#475569",
+ "paddingTop": "0px",
+ "paddingBottom": "10px"
+ }
+ },
+ {
+ "blockType": "link",
+ "settings": {
+ "text": "Claim your spot",
+ "url": "#",
+ "alignment": "left",
+ "isButton": true,
+ "buttonColor": "#db2777",
+ "buttonTextColor": "#ffffff",
+ "buttonBorderRadius": "999px",
+ "buttonPaddingX": "22px",
+ "buttonPaddingY": "12px",
+ "buttonBorderWidth": "0px",
+ "buttonBorderStyle": "solid",
+ "buttonBorderColor": "#db2777",
+ "paddingTop": "6px",
+ "paddingBottom": "16px"
+ }
+ },
+ {
+ "blockType": "separator",
+ "settings": {
+ "color": "#fbcfe8",
+ "thickness": "1px",
+ "style": "solid",
+ "paddingTop": "0px",
+ "paddingBottom": "14px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "## Why this template works\n\n- Fast visual hierarchy.\n- Clean body copy.\n- Strong CTA treatment.\n- Enough polish to feel current without being overdesigned.",
+ "alignment": "left",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "16px",
+ "lineHeight": "1.7",
+ "foregroundColor": "#111827",
+ "paddingTop": "0px",
+ "paddingBottom": "8px"
+ }
+ },
+ {
+ "blockType": "link",
+ "settings": {
+ "text": "Learn more",
+ "url": "#",
+ "alignment": "left",
+ "isButton": false,
+ "textColor": "#db2777",
+ "fontSize": "14px",
+ "textDecoration": "none",
+ "paddingTop": "0px",
+ "paddingBottom": "0px"
+ }
+ },
+ {
+ "blockType": "separator",
+ "settings": {
+ "color": "#fce7f3",
+ "thickness": "1px",
+ "style": "solid",
+ "paddingTop": "18px",
+ "paddingBottom": "10px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "{{address}}\n\n[Unsubscribe]({{unsubscribe_link}})",
+ "alignment": "center",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "12px",
+ "foregroundColor": "#64748b",
+ "paddingTop": "0px",
+ "paddingBottom": "0px"
+ }
+ }
+ ]
+ }
+}
diff --git a/apps/web/templates/system-emails/blank.json b/apps/web/templates/system-emails/blank.json
new file mode 100644
index 000000000..ceba64797
--- /dev/null
+++ b/apps/web/templates/system-emails/blank.json
@@ -0,0 +1,104 @@
+{
+ "templateId": "system-5",
+ "title": "Blank",
+ "content": {
+ "style": {
+ "colors": {
+ "background": "#ffffff",
+ "foreground": "#111827",
+ "border": "#ffffff",
+ "accent": "#2563eb",
+ "accentForeground": "#ffffff"
+ },
+ "typography": {
+ "header": {
+ "fontFamily": "Helvetica, sans-serif",
+ "letterSpacing": "0px",
+ "textTransform": "none",
+ "textDecoration": "none"
+ },
+ "text": {
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "16px",
+ "lineHeight": "1.7",
+ "letterSpacing": "0px",
+ "textTransform": "none",
+ "textDecoration": "none"
+ },
+ "link": {
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "14px",
+ "lineHeight": "1.5",
+ "letterSpacing": "0px",
+ "textTransform": "none",
+ "textDecoration": "none"
+ }
+ },
+ "interactives": {
+ "button": {
+ "padding": {
+ "x": "20px",
+ "y": "10px"
+ },
+ "border": {
+ "width": "0px",
+ "radius": "8px",
+ "style": "solid"
+ }
+ },
+ "link": {
+ "padding": {
+ "x": "0px",
+ "y": "0px"
+ }
+ }
+ },
+ "structure": {
+ "page": {
+ "background": "#ffffff",
+ "foreground": "#111827",
+ "width": "640px",
+ "marginY": "24px",
+ "borderWidth": "0px",
+ "borderStyle": "solid",
+ "borderRadius": "0px"
+ },
+ "section": {
+ "padding": {
+ "x": "32px",
+ "y": "18px"
+ }
+ }
+ }
+ },
+ "meta": {
+ "previewText": "A blank starter template with only content and unsubscribe placeholders."
+ },
+ "content": [
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "# Start writing here\n\nAdd your message, links, and call to action.",
+ "alignment": "left",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "16px",
+ "foregroundColor": "#111827",
+ "paddingTop": "12px",
+ "paddingBottom": "12px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "{{address}}\n\n[Unsubscribe]({{unsubscribe_link}})",
+ "alignment": "center",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "12px",
+ "foregroundColor": "#64748b",
+ "paddingTop": "8px",
+ "paddingBottom": "0px"
+ }
+ }
+ ]
+ }
+}
diff --git a/apps/web/templates/system-emails/new-user-welcome.json b/apps/web/templates/system-emails/new-user-welcome.json
new file mode 100644
index 000000000..66875f95c
--- /dev/null
+++ b/apps/web/templates/system-emails/new-user-welcome.json
@@ -0,0 +1,183 @@
+{
+ "templateId": "system-2",
+ "title": "New user welcome",
+ "content": {
+ "style": {
+ "colors": {
+ "background": "#f8fafc",
+ "foreground": "#111827",
+ "border": "#dbe4ee",
+ "accent": "#2563eb",
+ "accentForeground": "#ffffff"
+ },
+ "typography": {
+ "header": {
+ "fontFamily": "Helvetica, sans-serif",
+ "letterSpacing": "-0.2px",
+ "textTransform": "none",
+ "textDecoration": "none"
+ },
+ "text": {
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "16px",
+ "lineHeight": "1.7",
+ "letterSpacing": "0px",
+ "textTransform": "none",
+ "textDecoration": "none"
+ },
+ "link": {
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "14px",
+ "lineHeight": "1.5",
+ "letterSpacing": "0.2px",
+ "textTransform": "uppercase",
+ "textDecoration": "none"
+ }
+ },
+ "interactives": {
+ "button": {
+ "padding": {
+ "x": "22px",
+ "y": "12px"
+ },
+ "border": {
+ "width": "0px",
+ "radius": "999px",
+ "style": "solid"
+ }
+ },
+ "link": {
+ "padding": {
+ "x": "0px",
+ "y": "0px"
+ }
+ }
+ },
+ "structure": {
+ "page": {
+ "background": "#ffffff",
+ "foreground": "#111827",
+ "width": "640px",
+ "marginY": "24px",
+ "borderWidth": "1px",
+ "borderStyle": "solid",
+ "borderRadius": "20px"
+ },
+ "section": {
+ "padding": {
+ "x": "32px",
+ "y": "18px"
+ }
+ }
+ }
+ },
+ "meta": {
+ "previewText": "A friendly welcome email for onboarding new users and subscribers."
+ },
+ "content": [
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "WELCOME",
+ "alignment": "center",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "12px",
+ "foregroundColor": "#2563eb",
+ "paddingTop": "8px",
+ "paddingBottom": "0px"
+ }
+ },
+ {
+ "blockType": "image",
+ "settings": {
+ "src": "https://upload.wikimedia.org/wikipedia/commons/d/d5/Slack_icon_2019.svg",
+ "alt": "Slack logo",
+ "alignment": "center",
+ "width": "48px",
+ "maxWidth": "100%",
+ "paddingTop": "10px",
+ "paddingBottom": "6px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "# Welcome aboard",
+ "alignment": "left",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "26px",
+ "foregroundColor": "#111827",
+ "paddingTop": "6px",
+ "paddingBottom": "10px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "Thanks for joining us. This template works well for onboarding, first-touch education, getting-started checklists, and setting expectations for what comes next.",
+ "alignment": "left",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "17px",
+ "lineHeight": "1.7",
+ "foregroundColor": "#475569",
+ "paddingTop": "0px",
+ "paddingBottom": "10px"
+ }
+ },
+ {
+ "blockType": "link",
+ "settings": {
+ "text": "Get started",
+ "url": "#",
+ "alignment": "left",
+ "isButton": true,
+ "buttonColor": "#2563eb",
+ "buttonTextColor": "#ffffff",
+ "buttonBorderRadius": "999px",
+ "buttonPaddingX": "22px",
+ "buttonPaddingY": "12px",
+ "buttonBorderWidth": "0px",
+ "buttonBorderStyle": "solid",
+ "buttonBorderColor": "#2563eb",
+ "paddingTop": "6px",
+ "paddingBottom": "16px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "## What to do next\n\n- Complete your profile or setup.\n- Explore your dashboard or core feature.\n- Save this email for quick access later.",
+ "alignment": "left",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "16px",
+ "lineHeight": "1.7",
+ "foregroundColor": "#111827",
+ "paddingTop": "0px",
+ "paddingBottom": "8px"
+ }
+ },
+ {
+ "blockType": "separator",
+ "settings": {
+ "color": "#dbe4ee",
+ "thickness": "1px",
+ "style": "solid",
+ "paddingTop": "12px",
+ "paddingBottom": "10px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "{{address}}\n\n[Unsubscribe]({{unsubscribe_link}})",
+ "alignment": "center",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "12px",
+ "foregroundColor": "#64748b",
+ "paddingTop": "0px",
+ "paddingBottom": "0px"
+ }
+ }
+ ]
+ }
+}
diff --git a/apps/web/templates/system-emails/newsletter.json b/apps/web/templates/system-emails/newsletter.json
new file mode 100644
index 000000000..cbf9545bd
--- /dev/null
+++ b/apps/web/templates/system-emails/newsletter.json
@@ -0,0 +1,208 @@
+{
+ "templateId": "system-4",
+ "title": "Newsletter",
+ "content": {
+ "style": {
+ "colors": {
+ "background": "#f8fafc",
+ "foreground": "#0f172a",
+ "border": "#dbe4ee",
+ "accent": "#0f766e",
+ "accentForeground": "#ffffff"
+ },
+ "typography": {
+ "header": {
+ "fontFamily": "Arial, sans-serif",
+ "letterSpacing": "-0.2px",
+ "textTransform": "none",
+ "textDecoration": "none"
+ },
+ "text": {
+ "fontFamily": "Arial, sans-serif",
+ "fontSize": "16px",
+ "lineHeight": "1.7",
+ "letterSpacing": "0px",
+ "textTransform": "none",
+ "textDecoration": "none"
+ },
+ "link": {
+ "fontFamily": "Arial, sans-serif",
+ "fontSize": "14px",
+ "lineHeight": "1.5",
+ "letterSpacing": "0.2px",
+ "textTransform": "uppercase",
+ "textDecoration": "none"
+ }
+ },
+ "interactives": {
+ "button": {
+ "padding": {
+ "x": "20px",
+ "y": "11px"
+ },
+ "border": {
+ "width": "0px",
+ "radius": "8px",
+ "style": "solid"
+ }
+ },
+ "link": {
+ "padding": {
+ "x": "0px",
+ "y": "0px"
+ }
+ }
+ },
+ "structure": {
+ "page": {
+ "background": "#ffffff",
+ "foreground": "#0f172a",
+ "width": "640px",
+ "marginY": "24px",
+ "borderWidth": "1px",
+ "borderStyle": "solid",
+ "borderRadius": "20px"
+ },
+ "section": {
+ "padding": {
+ "x": "32px",
+ "y": "18px"
+ }
+ }
+ }
+ },
+ "meta": {
+ "previewText": "A clean, editorial newsletter for recurring updates and curated stories."
+ },
+ "content": [
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "WEEKLY NEWSLETTER",
+ "alignment": "left",
+ "fontFamily": "Arial, sans-serif",
+ "fontSize": "12px",
+ "foregroundColor": "#0f766e",
+ "paddingTop": "6px",
+ "paddingBottom": "0px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "# What happened this week",
+ "alignment": "left",
+ "fontFamily": "Arial, sans-serif",
+ "fontSize": "26px",
+ "foregroundColor": "#0f172a",
+ "paddingTop": "10px",
+ "paddingBottom": "10px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "Use this for curated updates, editorial roundups, key announcements, and useful links your audience will want to save.",
+ "alignment": "left",
+ "fontFamily": "Arial, sans-serif",
+ "fontSize": "17px",
+ "lineHeight": "1.7",
+ "foregroundColor": "#475569",
+ "paddingTop": "0px",
+ "paddingBottom": "14px"
+ }
+ },
+ {
+ "blockType": "separator",
+ "settings": {
+ "color": "#dbe4ee",
+ "thickness": "1px",
+ "style": "solid",
+ "paddingTop": "0px",
+ "paddingBottom": "14px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "## Featured update\n\nShare the biggest story or insight first. A short explanation plus one strong CTA is usually enough.",
+ "alignment": "left",
+ "fontFamily": "Arial, sans-serif",
+ "fontSize": "16px",
+ "lineHeight": "1.7",
+ "foregroundColor": "#0f172a",
+ "paddingTop": "0px",
+ "paddingBottom": "8px"
+ }
+ },
+ {
+ "blockType": "link",
+ "settings": {
+ "text": "Read the feature",
+ "url": "#",
+ "alignment": "left",
+ "isButton": true,
+ "buttonColor": "#0f766e",
+ "buttonTextColor": "#ffffff",
+ "buttonBorderRadius": "8px",
+ "buttonPaddingX": "20px",
+ "buttonPaddingY": "11px",
+ "buttonBorderWidth": "0px",
+ "buttonBorderStyle": "solid",
+ "buttonBorderColor": "#0f766e",
+ "paddingTop": "4px",
+ "paddingBottom": "16px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "## Also inside\n\n- A quick tip or takeaway.\n- One resource worth sharing.\n- A subtle CTA to your offer or archive.",
+ "alignment": "left",
+ "fontFamily": "Arial, sans-serif",
+ "fontSize": "16px",
+ "lineHeight": "1.7",
+ "foregroundColor": "#0f172a",
+ "paddingTop": "0px",
+ "paddingBottom": "8px"
+ }
+ },
+ {
+ "blockType": "link",
+ "settings": {
+ "text": "Browse past issues",
+ "url": "#",
+ "alignment": "left",
+ "isButton": false,
+ "textColor": "#0f766e",
+ "fontSize": "14px",
+ "textDecoration": "none",
+ "paddingTop": "0px",
+ "paddingBottom": "0px"
+ }
+ },
+ {
+ "blockType": "separator",
+ "settings": {
+ "color": "#dbe4ee",
+ "thickness": "1px",
+ "style": "solid",
+ "paddingTop": "18px",
+ "paddingBottom": "10px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "{{address}}\n\n[Unsubscribe]({{unsubscribe_link}})",
+ "alignment": "center",
+ "fontFamily": "Arial, sans-serif",
+ "fontSize": "12px",
+ "foregroundColor": "#64748b",
+ "paddingTop": "0px",
+ "paddingBottom": "0px"
+ }
+ }
+ ]
+ }
+}
diff --git a/apps/web/templates/system-emails/plain-text.json b/apps/web/templates/system-emails/plain-text.json
deleted file mode 100644
index 507ff124a..000000000
--- a/apps/web/templates/system-emails/plain-text.json
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "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
deleted file mode 100644
index 5919938b5..000000000
--- a/apps/web/templates/system-emails/simple-announcement.json
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "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/templates/system-emails/upsell-products.json b/apps/web/templates/system-emails/upsell-products.json
new file mode 100644
index 000000000..1f8025987
--- /dev/null
+++ b/apps/web/templates/system-emails/upsell-products.json
@@ -0,0 +1,198 @@
+{
+ "templateId": "system-3",
+ "title": "Upsell products",
+ "content": {
+ "style": {
+ "colors": {
+ "background": "#f8fafc",
+ "foreground": "#0f172a",
+ "border": "#dbe4ee",
+ "accent": "#7c3aed",
+ "accentForeground": "#ffffff"
+ },
+ "typography": {
+ "header": {
+ "fontFamily": "Helvetica, sans-serif",
+ "letterSpacing": "-0.3px",
+ "textTransform": "none",
+ "textDecoration": "none"
+ },
+ "text": {
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "16px",
+ "lineHeight": "1.7",
+ "letterSpacing": "0px",
+ "textTransform": "none",
+ "textDecoration": "none"
+ },
+ "link": {
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "14px",
+ "lineHeight": "1.5",
+ "letterSpacing": "0.3px",
+ "textTransform": "uppercase",
+ "textDecoration": "none"
+ }
+ },
+ "interactives": {
+ "button": {
+ "padding": {
+ "x": "22px",
+ "y": "12px"
+ },
+ "border": {
+ "width": "0px",
+ "radius": "999px",
+ "style": "solid"
+ }
+ },
+ "link": {
+ "padding": {
+ "x": "0px",
+ "y": "0px"
+ }
+ }
+ },
+ "structure": {
+ "page": {
+ "background": "#ffffff",
+ "foreground": "#0f172a",
+ "width": "640px",
+ "marginY": "24px",
+ "borderWidth": "1px",
+ "borderStyle": "solid",
+ "borderRadius": "20px"
+ },
+ "section": {
+ "padding": {
+ "x": "32px",
+ "y": "18px"
+ }
+ }
+ }
+ },
+ "meta": {
+ "previewText": "A polished upsell email to spotlight related products and premium offers."
+ },
+ "content": [
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "RECOMMENDED FOR YOU",
+ "alignment": "center",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "12px",
+ "foregroundColor": "#7c3aed",
+ "backgroundColor": "#f5f3ff",
+ "paddingTop": "8px",
+ "paddingBottom": "8px"
+ }
+ },
+ {
+ "blockType": "image",
+ "settings": {
+ "src": "https://upload.wikimedia.org/wikipedia/commons/d/d5/Slack_icon_2019.svg",
+ "alt": "Slack logo",
+ "alignment": "center",
+ "width": "56px",
+ "maxWidth": "100%",
+ "paddingTop": "12px",
+ "paddingBottom": "4px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "# Products your customers are ready to buy next",
+ "alignment": "left",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "26px",
+ "foregroundColor": "#0f172a",
+ "paddingTop": "8px",
+ "paddingBottom": "10px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "Use this for upgrades, bundles, complementary offers, limited-time incentives, and premium add-ons that naturally follow an initial purchase.",
+ "alignment": "left",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "17px",
+ "lineHeight": "1.7",
+ "foregroundColor": "#475569",
+ "paddingTop": "0px",
+ "paddingBottom": "10px"
+ }
+ },
+ {
+ "blockType": "link",
+ "settings": {
+ "text": "View recommended products",
+ "url": "#",
+ "alignment": "left",
+ "isButton": true,
+ "buttonColor": "#7c3aed",
+ "buttonTextColor": "#ffffff",
+ "buttonBorderRadius": "999px",
+ "buttonPaddingX": "22px",
+ "buttonPaddingY": "12px",
+ "buttonBorderWidth": "0px",
+ "buttonBorderStyle": "solid",
+ "buttonBorderColor": "#7c3aed",
+ "paddingTop": "6px",
+ "paddingBottom": "16px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "## Why this converts\n\n- Relevant add-on recommendation.\n- Clear next step.\n- A premium but simple visual treatment.\n- Plenty of room for benefits, pricing, or testimonials.",
+ "alignment": "left",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "16px",
+ "lineHeight": "1.7",
+ "foregroundColor": "#0f172a",
+ "paddingTop": "0px",
+ "paddingBottom": "8px"
+ }
+ },
+ {
+ "blockType": "link",
+ "settings": {
+ "text": "See pricing",
+ "url": "#",
+ "alignment": "left",
+ "isButton": false,
+ "textColor": "#7c3aed",
+ "fontSize": "14px",
+ "textDecoration": "none",
+ "paddingTop": "0px",
+ "paddingBottom": "0px"
+ }
+ },
+ {
+ "blockType": "separator",
+ "settings": {
+ "color": "#e9d5ff",
+ "thickness": "1px",
+ "style": "solid",
+ "paddingTop": "18px",
+ "paddingBottom": "10px"
+ }
+ },
+ {
+ "blockType": "text",
+ "settings": {
+ "content": "{{address}}\n\n[Unsubscribe]({{unsubscribe_link}})",
+ "alignment": "center",
+ "fontFamily": "Helvetica, sans-serif",
+ "fontSize": "12px",
+ "foregroundColor": "#64748b",
+ "paddingTop": "0px",
+ "paddingBottom": "0px"
+ }
+ }
+ ]
+ }
+}
diff --git a/apps/web/ui-config/strings.ts b/apps/web/ui-config/strings.ts
index dcfdefe03..482738880 100644
--- a/apps/web/ui-config/strings.ts
+++ b/apps/web/ui-config/strings.ts
@@ -539,6 +539,8 @@ export const COURSE_STUDENT_SEARCH_BY_TEXT = "Search student";
export const COURSE_STUDENT_NO_RECORDS = "No student found";
export const QUESTION_BUILDER_DELETE_TOOLTIP = "Delete question";
export const PAGE_HEADER_EDIT_MAIL = "Compose mail";
+export const PAGE_HEADER_EDIT_TEMPLATE = "Template";
+export const PAGE_HEADER_MANAGE = "Manage";
export const PAGE_HEADER_EDIT_SEQUENCE = "Sequence details";
export const BTN_SEND = "Send";
export const DIALOG_SEND_HEADER = "Send mail";
@@ -573,6 +575,41 @@ 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 PAGE_HEADER_CHOOSE_TEMPLATE = "Choose a template";
+export const BTN_EDIT_TEMPLATE = "Edit template";
+export const BTN_DELETE_TEMPLATE = "Delete template";
+export const TEMPLATE_NAME_LABEL = "Template name";
+export const TEMPLATE_NAME_PLACEHOLDER = "Untitled template";
+export const TEMPLATE_SETTINGS_DESCRIPTION =
+ "Update the template name here. Use the preview below to open the editor when you want to change the content.";
+export const TEMPLATE_MANAGE_DESCRIPTION = "Manage your template settings";
+export const TEMPLATE_PREVIEW_HEADER = "Template preview";
+export const TEMPLATE_PREVIEW_DESCRIPTION =
+ "Review your template content and open the editor when you want to make changes.";
+export const MAIL_TEMPLATE_CHOOSER_HEADING = "Choose a template";
+export const MAIL_TEMPLATE_CHOOSER_DESCRIPTION =
+ "Start from a polished layout and customize it in the editor.";
+export const MAIL_TEMPLATE_CHOOSER_SYSTEM_SECTION = "System";
+export const MAIL_TEMPLATE_CHOOSER_SYSTEM_DESCRIPTION =
+ "Built-in starters for common email styles and use cases.";
+export const MAIL_TEMPLATE_CHOOSER_CUSTOM_SECTION = "My templates";
+export const MAIL_TEMPLATE_CHOOSER_CUSTOM_DESCRIPTION =
+ "Your saved templates, ready to reuse.";
+export const TEMPLATES_EMPTY_STATE_TITLE = "No templates yet";
+export const TEMPLATES_EMPTY_STATE_DESCRIPTION =
+ "Start with a system template and customize it to build your own reusable library.";
+export const TEMPLATES_EMPTY_STATE_CTA = "Create from template";
+export const TEMPLATE_CHOOSER_CUSTOM_EMPTY_STATE_TITLE =
+ "No saved templates yet";
+export const TEMPLATE_CHOOSER_CUSTOM_EMPTY_STATE_DESCRIPTION =
+ "Create one from a system template above and it will show up here for reuse.";
+export const MAIL_TEMPLATE_CHOOSER_BADGE_SYSTEM = "Starter";
+export const MAIL_TEMPLATE_CHOOSER_BADGE_CUSTOM = "Custom";
+export const DELETE_TEMPLATE_DIALOG_HEADER = "Delete template";
+export const DELETE_TEMPLATE_DIALOG_DESCRIPTION =
+ "This will permanently delete the template. This action cannot be undone.";
+export const TOAST_TEMPLATE_SAVED = "Template updated";
+export const TOAST_TEMPLATE_DELETED = "Template deleted";
export const MAIL_TABLE_HEADER_SUBJECT = "Subject";
export const MAIL_TABLE_HEADER_TITLE = "Title";
export const MAIL_TABLE_HEADER_RECEPIENTS = "No. of recipients";
@@ -756,3 +793,5 @@ export const LOGIN_CODE_SENT_MESSAGE =
"We have emailed you a one time password.";
export const LESSON_EMBED_URL_LABEL = "Embed code";
export const LESSON_CONTENT_LABEL = "Content";
+export const EMAIL_EDITOR_EMAIL_EDIT_HEADER = "Editing email";
+export const EMAIL_EDITOR_TEMPLATE_EDIT_HEADER = "Editing template";
From 408bb10f0a15b214488eedc0340a5f2ca5118375 Mon Sep 17 00:00:00 2001
From: Rajat
Date: Wed, 1 Apr 2026 11:53:22 +0530
Subject: [PATCH 5/6] Added email template docs
---
.../emails/email-editor-via-template.png | Bin 0 -> 776573 bytes
.../email-template-manage-edit-button.png | Bin 0 -> 586107 bytes
.../emails/email-template-manage-screen.png | Bin 0 -> 479105 bytes
.../email-template-selection-screen.png | Bin 0 -> 333981 bytes
.../assets/emails/email-templates-hub.png | Bin 0 -> 110480 bytes
apps/docs/src/config.ts | 4 +
.../pages/en/email-marketing/broadcasts.md | 9 +-
.../src/pages/en/email-marketing/sequences.md | 57 +++++-----
.../src/pages/en/email-marketing/templates.md | 99 ++++++++++++++++++
9 files changed, 139 insertions(+), 30 deletions(-)
create mode 100644 apps/docs/public/assets/emails/email-editor-via-template.png
create mode 100644 apps/docs/public/assets/emails/email-template-manage-edit-button.png
create mode 100644 apps/docs/public/assets/emails/email-template-manage-screen.png
create mode 100644 apps/docs/public/assets/emails/email-template-selection-screen.png
create mode 100644 apps/docs/public/assets/emails/email-templates-hub.png
create mode 100644 apps/docs/src/pages/en/email-marketing/templates.md
diff --git a/apps/docs/public/assets/emails/email-editor-via-template.png b/apps/docs/public/assets/emails/email-editor-via-template.png
new file mode 100644
index 0000000000000000000000000000000000000000..1aedf1e2d14b35b35c4427c91377879a43cd942a
GIT binary patch
literal 776573
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¾wBh?%>Y2@tr*4rHE$qIAPXvdFfi87paf}c98OI
zmiY@(J8t%buN1t5aG;qsTzYk@NWB0H=bZ!#Ogbb6k-Rm&3TB#~yVYGhVYJtK^|>2|o1
zW-eVqihgpw}sx(zu)Q`!Rh{2HP3jsl=#C+P#T>92_U
z*A15`!zU*iOR(uNWJL?lmIIJmZvg9VE?_`J_^AzTj@hPPes;fQ-)&sgysJbGQbO-2
zPnEg#juG_CrA=qo0$sG!jcVg6^jlF`!noj>a;jc9H^r0EeTg%}vChu`&v>-AxVIO%
zgg>4Vl9#o{MwK!Y{jnWjw;%p9MW-4Cwr4(ZT?iF*2Q~}KRQVgR`R^13Q>m2P4pzUX
z_+8)!#>h;)2zmra9i^u!5)#ys6_gj1j@^r04P6_Jfgb^8j~eJ$S!1lzKpyxom81Nw
zBt;3Ej~<1FpZ4frM!1sG1FpIg{cbedP{j@u2?9k9T~W?%Q_l9{F1G8>H`3qf87fGG
zq(kW$y1uFQHnZaIw(5_Ii|EXV>R!PV8k5?%d)S?L%EO
z6ye4TpVK5k+5oLy9)dT2w#JQP;7U*C6m>Rv@T)XWEUTjrw>htmh!zjYj8fqKR`gR1
z##e6{h<|=Hw64^;NPZVa1RWI-w_H*B7$L5cM#skc;f~k%xX@6Oaw5w;nJBaS{qM9{
zx}o4*C7ANMx+AUIGM89?z(`DMY0SWL_tG0Aximq+Je!ivs1mcZ(Sf?m<-?>~QGCQT
ztd{w4_ERq3Mt-kC58lY(QFSWfPmT|o9oet{J@uH*QumrPZbEz%5Ux$gv*p^+Dzo_f
z<)LCCuD9ThgFg=CNI?iRMh(Zkglc#4o-1*6+k`Wb!#9
z$2QJg0;sh?{ncACvPY{WbM |