diff --git a/apps/docs/public/assets/emails/analytics-dashboard.png b/apps/docs/public/assets/emails/analytics-dashboard.png new file mode 100644 index 000000000..f900eca09 Binary files /dev/null and b/apps/docs/public/assets/emails/analytics-dashboard.png differ diff --git a/apps/docs/public/assets/emails/analytics-performance.jpeg b/apps/docs/public/assets/emails/analytics-performance.jpeg new file mode 100644 index 000000000..ca29ad4ef Binary files /dev/null and b/apps/docs/public/assets/emails/analytics-performance.jpeg differ diff --git a/apps/docs/public/assets/emails/back-to-sequence-breadcrumb.png b/apps/docs/public/assets/emails/back-to-sequence-breadcrumb.png index ad5e029d4..6f71e1c56 100644 Binary files a/apps/docs/public/assets/emails/back-to-sequence-breadcrumb.png and b/apps/docs/public/assets/emails/back-to-sequence-breadcrumb.png differ diff --git a/apps/docs/public/assets/emails/broadcasts-hub.png b/apps/docs/public/assets/emails/broadcasts-hub.png index 95f081fb4..8bc8d2617 100644 Binary files a/apps/docs/public/assets/emails/broadcasts-hub.png and b/apps/docs/public/assets/emails/broadcasts-hub.png differ diff --git a/apps/docs/public/assets/emails/compose-broadcast.png b/apps/docs/public/assets/emails/compose-broadcast.png index 6a9cbc22c..53e2fe861 100644 Binary files a/apps/docs/public/assets/emails/compose-broadcast.png and b/apps/docs/public/assets/emails/compose-broadcast.png differ diff --git a/apps/docs/public/assets/emails/compose-sequence-add-email.jpeg b/apps/docs/public/assets/emails/compose-sequence-add-email.jpeg new file mode 100644 index 000000000..0314310f5 Binary files /dev/null and b/apps/docs/public/assets/emails/compose-sequence-add-email.jpeg differ diff --git a/apps/docs/public/assets/emails/compose-sequence-email-row.jpeg b/apps/docs/public/assets/emails/compose-sequence-email-row.jpeg new file mode 100644 index 000000000..0b6aff0e5 Binary files /dev/null and b/apps/docs/public/assets/emails/compose-sequence-email-row.jpeg differ diff --git a/apps/docs/public/assets/emails/compose-sequence-email.png b/apps/docs/public/assets/emails/compose-sequence-email.png index 92f93b22d..92de73776 100644 Binary files a/apps/docs/public/assets/emails/compose-sequence-email.png and b/apps/docs/public/assets/emails/compose-sequence-email.png differ diff --git a/apps/docs/public/assets/emails/compose-sequence.png b/apps/docs/public/assets/emails/compose-sequence.png index 54e77ebee..287aaa9cd 100644 Binary files a/apps/docs/public/assets/emails/compose-sequence.png and b/apps/docs/public/assets/emails/compose-sequence.png differ diff --git a/apps/docs/public/assets/emails/email-editor.png b/apps/docs/public/assets/emails/email-editor.png index 154232c9b..3cab502a5 100644 Binary files a/apps/docs/public/assets/emails/email-editor.png and b/apps/docs/public/assets/emails/email-editor.png differ diff --git a/apps/docs/public/assets/emails/scheduled-mail.jpeg b/apps/docs/public/assets/emails/scheduled-mail.jpeg deleted file mode 100644 index dfebdfdce..000000000 Binary files a/apps/docs/public/assets/emails/scheduled-mail.jpeg and /dev/null differ diff --git a/apps/docs/public/assets/emails/scheduled-mail.png b/apps/docs/public/assets/emails/scheduled-mail.png new file mode 100644 index 000000000..7a69c5d0a Binary files /dev/null and b/apps/docs/public/assets/emails/scheduled-mail.png differ diff --git a/apps/docs/public/assets/emails/sequences-hub.png b/apps/docs/public/assets/emails/sequences-hub.png index 56183b4ab..21bc0fa4b 100644 Binary files a/apps/docs/public/assets/emails/sequences-hub.png and b/apps/docs/public/assets/emails/sequences-hub.png differ diff --git a/apps/docs/src/config.ts b/apps/docs/src/config.ts index 00843b872..3a806ebec 100644 --- a/apps/docs/src/config.ts +++ b/apps/docs/src/config.ts @@ -93,6 +93,10 @@ export const SIDEBAR: Sidebar = { text: "Sequences (Campaigns)", link: "en/email-marketing/sequences", }, + { + text: "Analytics", + link: "en/email-marketing/analytics", + }, ], Website: [ { text: "Introduction", link: "en/website/introduction" }, diff --git a/apps/docs/src/pages/en/email-marketing/analytics.md b/apps/docs/src/pages/en/email-marketing/analytics.md new file mode 100644 index 000000000..11eb53c04 --- /dev/null +++ b/apps/docs/src/pages/en/email-marketing/analytics.md @@ -0,0 +1,94 @@ +--- +title: Email Analytics +description: Track and analyze the performance of your email campaigns +layout: ../../../layouts/MainLayout.astro +--- + +Email analytics provide detailed insights into how your email campaigns are performing, helping you understand your audience engagement and optimize your email marketing strategy. + +> This feature is currently in beta, which means you may encounter bugs. Please report them in our Discord group if you run into any issues. + +> **Before you start**: If your school is hosted on [courselit.app](https://courselit.app), you need to get approved to send marketing emails. [Request access here](/en/email-marketing/mail-access-request). + +## Accessing Email Analytics + +From the `Dashboard`, go to `Mails` and select any broadcast or sequence you've sent. You'll see two tabs at the top: + +- **Compose**: For creating and editing emails +- **Analytics**: For viewing performance metrics + +Click on the `Analytics` tab to access detailed performance data for your email campaign. + +![Email Analytics Dashboard](/assets/emails/analytics-dashboard.png) + +## Understanding Email Performance Metrics + +The analytics dashboard is divided into two main sections that provide comprehensive insights into your email campaign performance. + +### Email Performance Section + +This section displays key performance indicators (KPIs) that help you understand how your email campaign is performing. Each metric is presented with a question mark icon (?) that provides additional context when hovered over. + +![Email Performance Section](/assets/emails/analytics-performance.jpeg) + +#### Subscribers + +- **What it shows**: The total number of subscribers who were targeted by this email campaign +- **How it's calculated**: Based on the filters you applied when creating the broadcast OR the number of subscribers in the sequence +- **Why it matters**: Helps you understand the reach of your campaign + +#### Emails Sent + +- **What it shows**: The actual number of emails that were successfully sent +- **How it's calculated**: Total emails dispatched +- **Why it matters**: Confirms that your emails are being delivered to your audience + +#### Open Rate + +- **What it shows**: The percentage of emails that were opened by recipients +- **How it's calculated**: (Number of opened emails ÷ Total emails sent) × 100 +- **Why it matters**: Indicates how engaging your subject lines and sender reputation are +- **Industry benchmark**: Typically ranges from 15-25% for most industries + +#### Click Rate + +- **What it shows**: The percentage of emails that resulted in at least one link click +- **How it's calculated**: (Number of emails with clicks ÷ Total emails sent) × 100 +- **Why it matters**: Measures the effectiveness of your email content and call-to-action buttons +- **Industry benchmark**: Usually between 2-5% for most campaigns + +#### Click-to-Open Rate + +- **What it shows**: The percentage of opened emails that resulted in at least one click +- **How it's calculated**: (Number of emails with clicks ÷ Number of opened emails) × 100 +- **Why it matters**: Shows how compelling your email content is to engaged readers +- **Industry benchmark**: Typically 10-20% for well-performing campaigns + +### Subscribers Section + +This section provides detailed information about individual subscribers. + +## Interpreting Your Analytics + +### High Performance Indicators + +- **Open Rate > 20%**: Your subject lines are compelling and your sender reputation is good +- **Click Rate > 3%**: Your email content is engaging and calls-to-action are effective +- **Click-to-Open Rate > 15%**: Your email content resonates well with your audience + +### Areas for Improvement + +- **Low Open Rate**: Consider improving subject lines, sender name, or timing +- **Low Click Rate**: Review your email content, design, and call-to-action placement +- **Low Click-to-Open Rate**: Focus on making your content more compelling for engaged readers + +## Next Steps + +Now that you understand your email analytics, you can: + +- [Send a broadcasts](/en/email-marketing/broadcasts) +- [Set up automated email sequences](/en/email-marketing/sequences) + +## Stuck Somewhere? + +We are always here for you. Come chat with us in our Discord channel or send a tweet to @CourseLit. diff --git a/apps/docs/src/pages/en/email-marketing/broadcasts.md b/apps/docs/src/pages/en/email-marketing/broadcasts.md index 4092c90c5..a9be14f98 100644 --- a/apps/docs/src/pages/en/email-marketing/broadcasts.md +++ b/apps/docs/src/pages/en/email-marketing/broadcasts.md @@ -64,11 +64,14 @@ Click the `Schedule` button to see an additional input box to enter the date and Once an email is scheduled, you will see the time it will be sent at the bottom, as shown below. Simply click the `Cancel sending` button to cancel the scheduled send. -![Cancel Scheduled Mail](/assets/emails/scheduled-mail.jpeg) +![Cancel Scheduled Mail](/assets/emails/scheduled-mail.png) -## Next Step +## Next Steps -Let's see how to send automated email campaigns (also known as sequences) when something happens in your school. [Click here](/en/email-marketing/sequences). +Now that you understand how to send broadcasts, you can also see: + +- [Set up automated email sequences](/en/email-marketing/sequences) +- [Track your email performance with analytics](/en/email-marketing/analytics) ## Stuck Somewhere? diff --git a/apps/docs/src/pages/en/email-marketing/introduction.md b/apps/docs/src/pages/en/email-marketing/introduction.md index 51428e66a..64763c918 100644 --- a/apps/docs/src/pages/en/email-marketing/introduction.md +++ b/apps/docs/src/pages/en/email-marketing/introduction.md @@ -20,9 +20,12 @@ Send one-off emails to selected recipients for quick updates or newsletters. Automatically send a series of emails triggered by specific events in the system. -## Next Step +## Next Steps -Learn [how to send broadcast emails](/en/email-marketing/broadcasts) to your audience. +Now that you understand the email marketing capabilities, you can: + +- [Send broadcast emails](/en/email-marketing/broadcasts) +- [Set up automated email sequences](/en/email-marketing/sequences) ## Stuck Somewhere? diff --git a/apps/docs/src/pages/en/email-marketing/sequences.md b/apps/docs/src/pages/en/email-marketing/sequences.md index 4e1cb2f3c..d6d75a86d 100644 --- a/apps/docs/src/pages/en/email-marketing/sequences.md +++ b/apps/docs/src/pages/en/email-marketing/sequences.md @@ -24,7 +24,11 @@ Here, you will see all the sequences you have configured. 1. Click the `New sequence` button on the right, in the `Sequences` hub. -2. Let's get acquainted with the interface. In the following image, we have marked all the sections. To see the description of a section, note its number in the screenshot and find its description below. +You will be redirected to the sequence compose screen. The active tab will be `Compose`. + +2. Let's get acquainted with the interface. + +In the following image, we have marked all the sections. To see the description of a section, note its number in the screenshot and find its description below. - 1. **Sequence Name**: The internal name of the sequence. - 2. **From**: The sender's name that is displayed in the emails sent. @@ -47,15 +51,20 @@ Here, you will see all the sequences you have configured. 4. Start adding emails to this sequence. When you create a new sequence, an empty email is added to it by default. + ![Sequence add email](/assets/emails/compose-sequence-add-email.jpeg) + 5. Let's understand what information an email row shows: + ![Sequence email row](/assets/emails/compose-sequence-email-row.jpeg) + - 1. **Delay Since the Last Sent Email**: This shows the time to wait (in days) since the last email before dispatching this email. - 2. **Subject**: The subject of the email. - - 3. **Context Menu**: Contains options like `Delete`, etc. + - 3. **Published**: The status of the email. Only published emails are sent to users. + - 4. **Context Menu**: Contains options like `Delete`, etc. > The default email has `0 days` as the delay, which means the email will be sent immediately after the user enters the sequence, as it is the first email in the sequence. -6. To edit the body of an email, click on the subject. This will open the email compose screen as shown below. +6. To edit an email, click on the subject. This will open the email compose screen as shown below. 7. Let's get acquainted with the email compose interface: @@ -90,9 +99,12 @@ Here, you will see all the sequences you have configured. 11. Add more emails to the sequence by clicking on the `New email` button. 12. Keep editing your sequence until you think it's perfect. Once you are satisfied with your sequence, hit the `Start` button to begin sending this sequence to users. -## Next Step +## Next Steps + +Now that you understand how to create email sequences, you can also see: -Let's see how you can create and edit your website's pages. [Click here](/en/pages/introduction). +- [Send one-off broadcasts](/en/email-marketing/broadcasts) +- [Track your email performance with analytics](/en/email-marketing/analytics) ## Stuck Somewhere? diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/broadcast/[id]/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/broadcast/[id]/page.tsx index fbcacb989..fb6531f98 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/broadcast/[id]/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/broadcast/[id]/page.tsx @@ -4,19 +4,19 @@ import DashboardContent from "@components/admin/dashboard-content"; import { isDateInFuture } from "@/lib/utils"; import { AddressContext } from "@components/contexts"; import { BROADCASTS } from "@ui-config/strings"; -import { useContext } from "react"; +import { useContext, useState } from "react"; import { PaperPlane, Clock } from "@courselit/icons"; import { Form, FormField, Dialog2, useToast, + Tabbs, } from "@courselit/components-library"; import { ChangeEvent, FormEvent, useEffect, - useState, useRef, useCallback, useMemo, @@ -48,6 +48,8 @@ import { useGraphQLFetch } from "@/hooks/use-graphql-fetch"; import FilterContainer from "@components/admin/users/filter-container"; import EmailViewer from "@components/admin/mails/email-viewer"; import { Button } from "@components/ui/button"; +import { truncate } from "@courselit/utils"; +import EmailAnalytics from "@components/admin/mails/email-analytics"; const breadcrumbs = [ { label: BROADCASTS, href: "/dashboard/mails?tab=Broadcasts" }, @@ -77,6 +79,7 @@ export default function Page({ const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); const [report, setReport] = useState(); const [status, setStatus] = useState(null); + const [activeTab, setActiveTab] = useState("Compose"); // Refs to track initial values and prevent saving during load const initialValues = useRef({ @@ -501,147 +504,180 @@ export default function Page({ return ( -
-
-

- {PAGE_HEADER_EDIT_MAIL} -

-
-
- - {!isInitialLoad.current && ( - - )} -
-
- ) => - setSubject(e.target.value) - } - /> - - {showScheduleInput && ( - ) => { - const selectedDate = new Date(e.target.value); - setDelay(selectedDate.getTime()); - }} - /> - )} - {isEditable && ( -
- {!showScheduleInput && ( +
+

+ {truncate(subject || PAGE_HEADER_EDIT_MAIL, 50)} +

+
+ +
+
+
+ + {!isInitialLoad.current && ( + + )} +
+ + ) => + setSubject(e.target.value) + } + /> + + {showScheduleInput && ( + , + ) => { + const selectedDate = new Date( + e.target.value, + ); + setDelay(selectedDate.getTime()); + }} + /> + )} + {isEditable && (
- -
- - {BTN_SEND} + {!showScheduleInput && ( +
+ +
+ + {BTN_SEND} +
+ + } + onClick={onSubmit} + > +

+ Are you sure you want to + send this email to{" "} + {filteredUsersCount}{" "} + contacts? +

+
+ +
+ )} + {showScheduleInput && ( + <> + +
+ + {BTN_SCHEDULE} +
+ + } + onClick={(e) => + onSubmit(e, true) + } + > +
+

+ Are you sure you want to + schedule this email to{" "} + {filteredUsersCount}{" "} + contacts? +

+
+ - } - onClick={onSubmit} - > -

- Are you sure you want to send this - email to {filteredUsersCount}{" "} - contacts? -

- - + + )}
)} - {showScheduleInput && ( - <> - -
- - {BTN_SCHEDULE} -
- - } - onClick={(e) => onSubmit(e, true)} - > -
-

- Are you sure you want to - schedule this email to{" "} - {filteredUsersCount} contacts? -

-
-
+ + {status === "active" && + isDateInFuture(new Date(delay)) && + !report?.broadcast?.lockedAt && ( +
+

+ Scheduled for{" "} + {new Date(delay).toLocaleString()} +

- +
)} -
- )} - - {status === "active" && - isDateInFuture(new Date(delay)) && - !report?.broadcast?.lockedAt && ( -
-

- Scheduled for{" "} - {new Date(delay).toLocaleString()} -

- -
- )} -
+
+
+
+ +
+ ); } diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/sequence/[id]/[mailId]/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/sequence/[id]/[mailId]/page.tsx index 3f289ccf6..b13ec1323 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/sequence/[id]/[mailId]/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/sequence/[id]/[mailId]/page.tsx @@ -45,7 +45,6 @@ export default function Page({ ]; const [delay, setDelay] = useState(0); const [subject, setSubject] = useState(""); - // const [previewText, setPreviewText] = useState(""); const [content, setContent] = useState(null); const [email, setEmail] = useState(null); const [published, setPublished] = useState<"unpublished" | "published">( @@ -55,66 +54,6 @@ export default function Page({ const fetch = useGraphQLFetch(); const { sequence, loading, error, loadSequence } = useSequence(); - // const loadSequence = useCallback(async () => { - // const query = ` - // query GetSequence($sequenceId: String!) { - // sequence: getSequence(sequenceId: $sequenceId) { - // sequenceId, - // title, - // emails { - // emailId, - // subject, - // delayInMillis, - // published, - // content { - // content { - // blockType, - // settings - // }, - // style, - // meta - // }, - // }, - // trigger { - // type, - // data - // }, - // from { - // name, - // email - // }, - // emailsOrder, - // status - // } - // }`; - - // const fetcher = fetch - // .setPayload({ query, variables: { sequenceId } }) - // .build(); - - // try { - // const response = await fetcher.exec(); - // if (response.sequence) { - // const { sequence } = response; - // const email = sequence.emails.find((e) => e.emailId === mailId); - // if (email) { - // setEmail(email); - // setDelay(email.delayInMillis / 86400000); - // setSubject(email.subject); - // setPreviewText(email.previewText || ""); - // setContent(email.content); - // setPublished(email.published ? "published" : "unpublished"); - // } - // } - // } catch (e: any) { - // toast({ - // title: TOAST_TITLE_ERROR, - // description: e.message, - // variant: "destructive", - // }); - // } - // }, [fetch, sequenceId]); - useEffect(() => { loadSequence(sequenceId); }, [loadSequence, sequenceId]); @@ -297,7 +236,11 @@ export default function Page({ value={published} onChange={( value: "unpublished" | "published", - ) => setPublished(value)} + ) => { + if (value) { + setPublished(value); + } + }} title="Status" options={[ { @@ -319,30 +262,7 @@ export default function Page({ setSubject(e.target.value) } /> - {/* ) => - setPreviewText(e.target.value) - } - tooltip="This text will be shown in the email client before opening the email." - /> */} - {/* */} (null); + const [emails, setEmails] = useState([]); + const [tags, setTags] = useState([]); + const [products, setProducts] = useState< + Pick[] + >([]); + const [communities, setCommunities] = useState< + Pick[] + >([]); + const [emailsOrder, setEmailsOrder] = useState([]); + const [status, setStatus] = useState(null); + const { toast } = useToast(); + const fetch = useGraphQLFetch(); + + // Load sequence on mount + useEffect(() => { + loadSequence(id); + }, [loadSequence, id]); + + // Update local state when sequence data is loaded + useEffect(() => { + if (sequence) { + setTitle(sequence.title || ""); + setFrom(sequence.from?.name || ""); + setFromEmail(sequence.from?.email || ""); + setTriggerType(sequence.trigger?.type); + setTriggerData(sequence.trigger?.data || null); + setEmails(sequence.emails || []); + setEmailsOrder(sequence.emailsOrder || []); + setStatus(sequence.status); + } + }, [sequence]); + + const getTags = useCallback(async () => { + const query = ` + query { + tags: tagsWithDetails { + tag, + count + } + } + `; + const fetcher = fetch.setPayload(query).build(); + try { + const response = await fetcher.exec(); + if (response.tags) { + setTags(response.tags); + } + } catch (err) {} + }, [fetch]); + + const getProducts = useCallback(async () => { + const query = ` + query { courses: getCoursesAsAdmin( + offset: 1 + ) { + title, + courseId, + } + } + `; + const fetcher = fetch.setPayload(query).build(); + try { + const response = await fetcher.exec(); + if (response.courses) { + setProducts([...response.courses]); + } + } catch (err: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } + }, [fetch, toast]); + + const getCommunities = useCallback(async () => { + const query = ` + query { + communities: getCommunities(page: 1, limit: 1000000) { + communityId, + name + } + } + `; + const fetcher = fetch.setPayload(query).build(); + try { + const response = await fetcher.exec(); + if (response.communities) { + setCommunities([...response.communities]); + } + } catch (err: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } + }, [fetch, toast]); + + useEffect(() => { + if ( + (triggerType === "TAG_ADDED" || triggerType === "TAG_REMOVED") && + tags.length === 0 + ) { + getTags(); + } + if (triggerType === "PRODUCT_PURCHASED" && products.length === 0) { + getProducts(); + } + if ( + triggerType === "COMMUNITY_JOINED" || + (triggerType === "COMMUNITY_LEFT" && communities.length === 0) + ) { + getCommunities(); + } + }, [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 updateSequence = useCallback(async () => { + const query = ` + mutation UpdateSequence( + $sequenceId: String! + $title: String! + $fromName: String! + $triggerType: SequenceTriggerType! + $triggerData: String + $emailsOrder: [String!] + ) { + sequence: updateSequence( + sequenceId: $sequenceId + title: $title, + fromName: $fromName, + triggerType: $triggerType, + triggerData: $triggerData, + emailsOrder: $emailsOrder + ) { + sequenceId, + } + }`; + + const fetcher = fetch + .setPayload({ + query, + variables: { + sequenceId: id, + title, + fromName: from, + fromEmail, + triggerType, + triggerData, + emailsOrder, + }, + }) + .build(); + + try { + const response = await fetcher.exec(); + if (response.sequence) { + // Reload sequence data after action + await loadSequence(id); + toast({ + title: TOAST_TITLE_SUCCESS, + description: TOAST_SEQUENCE_SAVED, + }); + } + } catch (e: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: e.message, + variant: "destructive", + }); + } + }, [ + fetch, + id, + title, + from, + fromEmail, + triggerType, + triggerData, + emailsOrder, + loadSequence, + toast, + ]); + + const deleteMail = useCallback( + async ({ emailId }: { emailId: string }) => { + const query = ` + mutation DeleteMailFromSequence( + $sequenceId: String! + $emailId: String! + ) { + sequence: deleteMailFromSequence( + sequenceId: $sequenceId, + emailId: $emailId + ) { + sequenceId, + } + }`; + + const fetcher = fetch + .setPayload({ + query, + variables: { + sequenceId: id, + emailId, + }, + }) + .build(); + + try { + const response = await fetcher.exec(); + if (response.sequence) { + await loadSequence(id); + } + } catch (e: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: e.message, + variant: "destructive", + }); + } + }, + [fetch, id, loadSequence, toast], + ); + + const onSubmit = async (e: FormEvent) => { + e.preventDefault(); + await updateSequence(); + }; + + const startSequence = useCallback( + async (action: "start" | "pause") => { + setButtonLoading(true); + const query = + action === "start" + ? ` + mutation StartSequence( + $sequenceId: String! + ) { + sequence: startSequence( + sequenceId: $sequenceId + ) { + sequenceId, + } + }` + : ` + mutation PauseSequence( + $sequenceId: String! + ) { + sequence: pauseSequence( + sequenceId: $sequenceId + ) { + sequenceId, + } + }`; + + const fetcher = fetch + .setPayload({ + query, + variables: { + sequenceId: id, + }, + }) + .build(); + + try { + const response = await fetcher.exec(); + if (response.sequence) { + await loadSequence(id); + } + } catch (e: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: e.message, + variant: "destructive", + }); + } finally { + setButtonLoading(false); + } + }, + [fetch, id, loadSequence, toast], + ); + + if (loading || !sequence) { + return null; + } return ( - + {status && (status === "draft" || status === "paused") && ( +
+ {SEQUENCE_UNPUBLISHED_WARNING}{" "} +
+ )} +
+

+ {truncate(title || PAGE_HEADER_EDIT_SEQUENCE, 50)} +

+
+ {(sequence.status === "draft" || + sequence.status === "paused") && ( + + )} + {sequence.status === "active" && ( + + )} +
+
+ +
+
+
+ ) => + setTitle(e.target.value) + } + /> + ) => + setFrom(e.target.value) + } + placeholder={COMPOSE_SEQUENCE_FROM_PLC} + /> + + setTriggerData(value) + } + title={ + COMPOSE_SEQUENCE_ENTRANCE_CONDITION_DATA + } + options={(() => { + switch (triggerType) { + case "TAG_ADDED": + case "TAG_REMOVED": + return tags.map((tag) => ({ + label: tag.tag, + value: tag.tag, + })); + case "PRODUCT_PURCHASED": + return products.map( + (product) => ({ + label: product.title, + value: product.courseId, + }), + ); + case "COMMUNITY_JOINED": + case "COMMUNITY_LEFT": + return communities.map( + (community) => ({ + label: community.name, + value: community.communityId, + }), + ); + default: + return []; + } + })()} + /> + )} +
+ +
+ +
+

Emails

+
+ {emails.map((email) => ( +
+ + {Math.round( + email.delayInMillis / + (1000 * 60 * 60 * 24), + )}{" "} + day + + +
+ {truncate(email.subject, 70)} +
+ + {!email.published && ( + + Draft + + )} + } + variant="soft" + > + + deleteMail({ + emailId: email.emailId, + }) + } + /> + +
+ ))} +
+
+ +
+
+
+
+
+ +
+
); } diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/sequence/layout.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/sequence/layout.tsx new file mode 100644 index 000000000..425c91175 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/sequence/layout.tsx @@ -0,0 +1,22 @@ +import { PAGE_HEADER_EDIT_SEQUENCE } from "@ui-config/strings"; +import { Metadata, ResolvingMetadata } from "next"; +import { ReactNode } from "react"; + +export async function generateMetadata( + { + params, + searchParams, + }: { + params: any; + searchParams: { [key: string]: string | string[] | undefined }; + }, + parent: ResolvingMetadata, +): Promise { + return { + title: `${PAGE_HEADER_EDIT_SEQUENCE} | ${(await parent)?.title?.absolute}`, + }; +} + +export default function Layout({ children }: { children: ReactNode }) { + return children; +} diff --git a/apps/web/components/admin/mails/email-analytics.tsx b/apps/web/components/admin/mails/email-analytics.tsx new file mode 100644 index 000000000..d69ab7942 --- /dev/null +++ b/apps/web/components/admin/mails/email-analytics.tsx @@ -0,0 +1,266 @@ +import { useEffect, useState } from "react"; +import { useGraphQLFetch } from "@/hooks/use-graphql-fetch"; +import { useToast } from "@courselit/components-library"; +import { TOAST_TITLE_ERROR } from "@ui-config/strings"; +import { Sequence } from "@courselit/common-models"; +import SubscribersList from "./subscribers-list"; +import { HelpCircle } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@components/ui/tooltip"; + +interface EmailAnalyticsProps { + sequence: Sequence; +} + +interface AnalyticsData { + totalEmailsSent: number; + openRate: number; + clickThroughRate: number; + clickToOpenRate: number; +} + +export default function EmailAnalytics({ sequence }: EmailAnalyticsProps) { + const [analytics, setAnalytics] = useState(null); + const [loading, setLoading] = useState(false); + const { toast } = useToast(); + const fetch = useGraphQLFetch(); + + useEffect(() => { + if (sequence?.sequenceId && sequence.entrantsCount > 0) { + loadAnalytics(); + } + }, [sequence]); + + const loadAnalytics = async () => { + setLoading(true); + + const query = ` + query GetEmailAnalytics($sequenceId: String!) { + totalEmailsSent: getEmailSentCount(sequenceId: $sequenceId) + openRate: getSequenceOpenRate(sequenceId: $sequenceId) + clickThroughRate: getSequenceClickThroughRate(sequenceId: $sequenceId) + } + `; + + const fetcher = fetch + .setPayload({ + query, + variables: { sequenceId: sequence.sequenceId }, + }) + .build(); + + try { + const response = await fetcher.exec(); + + if (response) { + const { totalEmailsSent, openRate, clickThroughRate } = + response; + + // Calculate CTOR (Click-to-Open Rate) = CTR / Open Rate + const clickToOpenRate = + openRate > 0 ? (clickThroughRate / openRate) * 100 : 0; + + setAnalytics({ + totalEmailsSent, + openRate: openRate, + clickThroughRate: clickThroughRate, + clickToOpenRate, + }); + } + } catch (e: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: e.message, + variant: "destructive", + }); + } finally { + setLoading(false); + } + }; + + if (loading) { + return ( +
+
+
+
+
+
+
+
+ ); + } + + // Show placeholder for broadcast that has been sent but analytics not yet available + if (sequence.entrantsCount <= 0) { + return ( +
+
+
+
+ + + +
+

+ Analytics Not Available +

+

+ Analytics will be available once the{" "} + {sequence.type.toLowerCase()} is sent to a few + subscribers. +

+
+
+
+ ); + } + + if (!analytics) { + return ( +
+ No analytics data available for this sequence. +
+ ); + } + + return ( +
+ {/* Compact Analytics Summary */} +
+

+ Email Performance +

+ +
+
+
+
+ Subscribers +
+ + + + + +

+ Total number of people subscribed to + this sequence +

+
+
+
+
+ {sequence.entrantsCount.toLocaleString()} +
+
+ +
+
+
+ Emails Sent +
+ + + + + +

+ Total number of emails delivered to + subscribers +

+
+
+
+
+ {analytics.totalEmailsSent.toLocaleString()} +
+
+ +
+
+
+ Open Rate +
+ + + + + +

+ Percentage of recipients who opened + the email +

+
+
+
+
+ {analytics.openRate.toFixed(1)}% +
+
+ +
+
+
+ Click Rate +
+ + + + + +

+ Percentage of recipients who clicked + a link in the email +

+
+
+
+
+ {analytics.clickThroughRate.toFixed(1)}% +
+
+ +
+
+
+ Click-to-Open +
+ + + + + +

+ Percentage of email openers who + clicked a link +

+
+
+
+
+ {analytics.clickToOpenRate.toFixed(1)}% +
+
+
+
+
+ + +
+ ); +} diff --git a/apps/web/components/admin/mails/sequence-editor.tsx b/apps/web/components/admin/mails/sequence-editor.tsx deleted file mode 100644 index 75e9fc57e..000000000 --- a/apps/web/components/admin/mails/sequence-editor.tsx +++ /dev/null @@ -1,794 +0,0 @@ -import { - Address, - Community, - Constants, - Course, -} from "@courselit/common-models"; -import { - Form, - FormField, - Link, - Select, - Menu2, - MenuItem, - FormSubmit, - Skeleton, - useToast, - Badge, -} from "@courselit/components-library"; -import { Pause } from "@courselit/icons"; -import { Play } from "@courselit/icons"; -import { Add, MoreVert } from "@courselit/icons"; -import { AppDispatch } from "@courselit/state-management"; -import { FetchBuilder } from "@courselit/utils"; -import { - COMPOSE_SEQUENCE_ENTRANCE_CONDITION, - COMPOSE_SEQUENCE_ENTRANCE_CONDITION_DATA, - COMPOSE_SEQUENCE_FORM_FROM, - COMPOSE_SEQUENCE_FORM_TITLE, - COMPOSE_SEQUENCE_FROM_PLC, - DELETE_EMAIL_MENU, - TOAST_TITLE_ERROR, - PAGE_HEADER_EDIT_SEQUENCE, - SEQUENCE_UNPUBLISHED_WARNING, -} from "@ui-config/strings"; -import { - ChangeEvent, - FormEvent, - useCallback, - useEffect, - useMemo, - useState, -} from "react"; -import { Button } from "@components/ui/button"; - -interface SequenceEditorProps { - id: string; - address: Address; - dispatch?: AppDispatch; - loading?: boolean; -} - -interface TagWithDetails { - tag: string; -} - -const SequenceEditor = ({ - id, - address, - loading = false, -}: SequenceEditorProps) => { - const [title, setTitle] = useState(""); - const [from, setFrom] = useState(""); - const [fromEmail, setFromEmail] = useState(""); - const [triggerType, setTriggerType] = useState("SUBSCRIBER_ADDED"); - const [triggerData, setTriggerData] = useState(""); - const [emails, setEmails] = useState([]); - const [sequence, setSequence] = useState(null); - const [tags, setTags] = useState([]); - const [products, setProducts] = useState< - Pick[] - >([]); - const [communities, setCommunities] = useState< - Pick[] - >([]); - const [emailsOrder, setEmailsOrder] = useState([]); - const [status, setStatus] = useState(null); - const { toast } = useToast(); - - const onSubmit = async (e: FormEvent, sendLater: boolean = false) => { - e.preventDefault(); - - await updateSequence(); - }; - - const fetch = useMemo( - () => - new FetchBuilder() - .setUrl(`${address.backend}/api/graph`) - .setIsGraphQLEndpoint(true), - [address.backend], - ); - - const loadSequence = useCallback(async () => { - const query = ` - query GetSequence($sequenceId: String!) { - sequence: getSequence(sequenceId: $sequenceId) { - sequenceId, - title, - emails { - emailId, - subject, - delayInMillis, - published - }, - trigger { - type, - data - }, - from { - name, - email - }, - emailsOrder, - status - } - }`; - - const fetcher = fetch - .setPayload({ query, variables: { sequenceId: id } }) - .build(); - - try { - const response = await fetcher.exec(); - if (response.sequence) { - const { sequence } = response; - setSequence(sequence); - setTitle(sequence.title); - setTriggerType(sequence.trigger?.type); - setTriggerData(sequence.trigger?.data); - setFrom(sequence.from?.name); - setFromEmail(sequence.from?.email); - setEmails(sequence.emails); - setEmailsOrder(sequence.emailsOrder); - setStatus(sequence.status); - } - } catch (e: any) { - toast({ - title: TOAST_TITLE_ERROR, - description: e.message, - variant: "destructive", - }); - } - }, [fetch, id]); - - useEffect(() => { - loadSequence(); - }, [loadSequence]); - - useEffect(() => { - if ( - (triggerType === "TAG_ADDED" || triggerType === "TAG_REMOVED") && - tags.length === 0 - ) { - getTags(); - } - if (triggerType === "PRODUCT_PURCHASED" && products.length === 0) { - getProducts(); - } - if ( - triggerType === "COMMUNITY_JOINED" || - (triggerType === "COMMUNITY_LEFT" && communities.length === 0) - ) { - getCommunities(); - } - }, [triggerType]); - - const getTags = useCallback(async () => { - const query = ` - query { - tags: tagsWithDetails { - tag, - count - } - } - `; - const fetcher = fetch.setPayload(query).build(); - try { - const response = await fetcher.exec(); - if (response.tags) { - setTags(response.tags); - } - } catch (err) {} - }, [fetch]); - - const getProducts = useCallback(async () => { - const query = ` - query { courses: getCoursesAsAdmin( - offset: 1 - ) { - title, - courseId, - } - } - `; - const fetcher = fetch.setPayload(query).build(); - try { - const response = await fetcher.exec(); - if (response.courses) { - setProducts([...response.courses]); - } - } catch (err: any) { - toast({ - title: TOAST_TITLE_ERROR, - description: err.message, - variant: "destructive", - }); - } - }, [fetch]); - - const getCommunities = useCallback(async () => { - const query = ` - query { - communities: getCommunities(page: 1, limit: 1000000) { - communityId, - name - } - } - `; - const fetcher = fetch.setPayload(query).build(); - try { - const response = await fetcher.exec(); - if (response.communities) { - setCommunities([...response.communities]); - } - } catch (err: any) { - toast({ - title: TOAST_TITLE_ERROR, - description: err.message, - variant: "destructive", - }); - } - }, [, fetch]); - - const addMailToSequence = useCallback(async () => { - const query = ` - mutation AddMailToSequence($sequenceId: String!) { - sequence: addMailToSequence(sequenceId: $sequenceId) { - sequenceId, - title, - emails { - emailId, - subject, - delayInMillis, - published - }, - trigger { - type, - data - }, - from { - name, - email - }, - emailsOrder, - status - } - }`; - - const fetcher = fetch - .setPayload({ query, variables: { sequenceId: id } }) - .build(); - - try { - const response = await fetcher.exec(); - if (response.sequence) { - const { sequence } = response; - setSequence(sequence); - setTitle(sequence.title); - setTriggerType(sequence.trigger?.type); - setTriggerData(sequence.trigger?.data); - setFrom(sequence.from?.name); - setFromEmail(sequence.from?.email); - setEmails(sequence.emails); - setEmailsOrder(sequence.emailsOrder); - setStatus(sequence.status); - } - } catch (e: any) { - toast({ - title: TOAST_TITLE_ERROR, - description: e.message, - variant: "destructive", - }); - } - }, [fetch, id]); - - const updateSequence = useCallback(async () => { - const query = ` - mutation UpdateSequence( - $sequenceId: String! - $title: String! - $fromName: String! - $triggerType: SequenceTriggerType! - $triggerData: String - $emailsOrder: [String!] - ) { - sequence: updateSequence( - sequenceId: $sequenceId - title: $title, - fromName: $fromName, - triggerType: $triggerType, - triggerData: $triggerData, - emailsOrder: $emailsOrder - ) { - sequenceId, - title, - emails { - emailId, - subject, - delayInMillis, - published - }, - trigger { - type, - data - }, - from { - name, - email - }, - emailsOrder, - status - } - }`; - - const fetcher = fetch - .setPayload({ - query, - variables: { - sequenceId: id, - title, - fromName: from, - fromEmail, - triggerType, - triggerData, - emailsOrder, - }, - }) - .build(); - - try { - const response = await fetcher.exec(); - if (response.sequence) { - const { sequence } = response; - setSequence(sequence); - setTitle(sequence.title); - setTriggerType(sequence.trigger?.type); - setTriggerData(sequence.trigger?.data); - setFrom(sequence.from?.name); - setFromEmail(sequence.from?.email); - setEmails(sequence.emails); - setEmailsOrder(sequence.emailsOrder); - setStatus(sequence.status); - } - } catch (e: any) { - toast({ - title: TOAST_TITLE_ERROR, - description: e.message, - variant: "destructive", - }); - } - }, [ - fetch, - id, - title, - from, - fromEmail, - triggerType, - triggerData, - emailsOrder, - ]); - - const startSequence = useCallback( - async (action: "start" | "pause") => { - const query = - action === "start" - ? ` - mutation StartSequence( - $sequenceId: String! - ) { - sequence: startSequence( - sequenceId: $sequenceId - ) { - sequenceId, - title, - emails { - emailId, - subject, - delayInMillis, - published - }, - trigger { - type, - data - }, - from { - name, - email - }, - emailsOrder, - status - } - }` - : ` - mutation PauseSequence( - $sequenceId: String! - ) { - sequence: pauseSequence( - sequenceId: $sequenceId - ) { - sequenceId, - title, - emails { - emailId, - subject, - delayInMillis, - published - }, - trigger { - type, - data - }, - from { - name, - email - }, - emailsOrder, - status - } - }`; - - const fetcher = fetch - .setPayload({ - query, - variables: { - sequenceId: id, - }, - }) - .build(); - - try { - const response = await fetcher.exec(); - if (response.sequence) { - const { sequence } = response; - setSequence(sequence); - setTitle(sequence.title); - setTriggerType(sequence.trigger?.type); - setTriggerData(sequence.trigger?.data); - setFrom(sequence.from?.name); - setFromEmail(sequence.from?.email); - setEmails(sequence.emails); - setEmailsOrder(sequence.emailsOrder); - setStatus(sequence.status); - } - } catch (e: any) { - toast({ - title: TOAST_TITLE_ERROR, - description: e.message, - variant: "destructive", - }); - } - }, - [fetch, id], - ); - - const deleteMail = useCallback( - async ({ emailId }: { emailId: string }) => { - const query = ` - mutation DeleteMailFromSequence( - $sequenceId: String! - $emailId: String! - ) { - sequence: deleteMailFromSequence( - sequenceId: $sequenceId, - emailId: $emailId - ) { - sequenceId, - title, - emails { - emailId, - subject, - delayInMillis, - published - }, - trigger { - type, - data - }, - from { - name, - email - }, - emailsOrder, - status - } - }`; - - const fetcher = fetch - .setPayload({ - query, - variables: { - sequenceId: id, - emailId, - }, - }) - .build(); - - try { - const response = await fetcher.exec(); - if (response.sequence) { - const { sequence } = response; - setSequence(sequence); - setTitle(sequence.title); - setTriggerType(sequence.trigger?.type); - setTriggerData(sequence.trigger?.data); - setFrom(sequence.from?.name); - setFromEmail(sequence.from?.email); - setEmails(sequence.emails); - setEmailsOrder(sequence.emailsOrder); - setStatus(sequence.status); - } - } catch (e: any) { - toast({ - title: TOAST_TITLE_ERROR, - description: e.message, - variant: "destructive", - }); - } - }, - [fetch, id], - ); - - return ( -
- {[ - Constants.sequenceStatus[0], - Constants.sequenceStatus[2], - ].includes(status) && ( -
- {SEQUENCE_UNPUBLISHED_WARNING}{" "} -
- )} -
-

- {PAGE_HEADER_EDIT_SEQUENCE} -

-
- {[ - Constants.sequenceStatus[0], - Constants.sequenceStatus[2], - ].includes(status) && ( - - )} - {status === Constants.sequenceStatus[1] && ( - - )} -
-
- {!sequence && ( -
-
- - -
-
- - -
-
- - -
-
- )} - {sequence && ( -
- ) => - setTitle(e.target.value) - } - /> - ) => - setFrom(e.target.value) - } - placeholder={COMPOSE_SEQUENCE_FROM_PLC} - /> - {/*
- ) => - setFrom(e.target.value) - } - /> -
- setTriggerType(value)} - title={COMPOSE_SEQUENCE_ENTRANCE_CONDITION} - options={[ - { - label: "Tag added", - value: "TAG_ADDED", - }, - { - label: "Tag removed", - value: "TAG_REMOVED", - }, - { - label: "Product purchased", - value: "PRODUCT_PURCHASED", - }, - { - label: "Subscriber added", - value: "SUBSCRIBER_ADDED", - }, - { - label: "Community joined", - value: "COMMUNITY_JOINED", - }, - { - label: "Community left", - value: "COMMUNITY_LEFT", - }, - ]} - /> - {triggerType !== "SUBSCRIBER_ADDED" && ( -