diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 70f8aaf70..01ed12288 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -26,7 +26,6 @@ jobs: pnpm --filter @courselit/common-models build pnpm --filter @courselit/utils build pnpm --filter @courselit/text-editor build - pnpm --filter @courselit/state-management build pnpm --filter @courselit/components-library build pnpm --filter @courselit/page-primitives build pnpm --filter @courselit/page-blocks build 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/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/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 000000000..1aedf1e2d Binary files /dev/null and b/apps/docs/public/assets/emails/email-editor-via-template.png differ diff --git a/apps/docs/public/assets/emails/email-template-manage-edit-button.png b/apps/docs/public/assets/emails/email-template-manage-edit-button.png new file mode 100644 index 000000000..dce3f1ae1 Binary files /dev/null and b/apps/docs/public/assets/emails/email-template-manage-edit-button.png differ diff --git a/apps/docs/public/assets/emails/email-template-manage-screen.png b/apps/docs/public/assets/emails/email-template-manage-screen.png new file mode 100644 index 000000000..6038b9887 Binary files /dev/null and b/apps/docs/public/assets/emails/email-template-manage-screen.png differ diff --git a/apps/docs/public/assets/emails/email-template-selection-screen.png b/apps/docs/public/assets/emails/email-template-selection-screen.png new file mode 100644 index 000000000..6ce039ebf Binary files /dev/null and b/apps/docs/public/assets/emails/email-template-selection-screen.png differ diff --git a/apps/docs/public/assets/emails/email-templates-hub.png b/apps/docs/public/assets/emails/email-templates-hub.png new file mode 100644 index 000000000..c9f74b6f8 Binary files /dev/null and b/apps/docs/public/assets/emails/email-templates-hub.png differ diff --git a/apps/docs/src/config.ts b/apps/docs/src/config.ts index eb11b7704..978d4b23e 100644 --- a/apps/docs/src/config.ts +++ b/apps/docs/src/config.ts @@ -96,6 +96,10 @@ export const SIDEBAR: Sidebar = { text: "Sequences (Campaigns)", link: "en/email-marketing/sequences", }, + { + text: "Templates", + link: "en/email-marketing/templates", + }, { text: "Analytics", link: "en/email-marketing/analytics", diff --git a/apps/docs/src/pages/en/email-marketing/broadcasts.md b/apps/docs/src/pages/en/email-marketing/broadcasts.md index a9be14f98..a7a440da3 100644 --- a/apps/docs/src/pages/en/email-marketing/broadcasts.md +++ b/apps/docs/src/pages/en/email-marketing/broadcasts.md @@ -20,7 +20,9 @@ From the `Dashboard`, go to `Mails` to land on the `Broadcasts` hub. Here, you w 1. Click the `New broadcast` button on the right, in the `Broadcasts` 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. +2. You will see a template selection screen. Click the template you'd like to use as a starting point. After selecting it, You will be redirected to the broadcast management screen. The active tab will be `Compose`. + +3. 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. **User Filters**: To select the users. - 2. **Total Selected Users**: The total number of selected users as per the applied filters. @@ -33,7 +35,7 @@ From the `Dashboard`, go to `Mails` to land on the `Broadcasts` hub. Here, you w ![Broadcast Compose](/assets/emails/compose-broadcast.png) -3. Upon clicking the **Mail Edit** button, a full-page email editor will open where you can edit the email. +4. Upon clicking the **Mail Edit** button, a full-page email editor will open where you can edit the email. > When done, simply press the exit button. All changes are auto-saved. @@ -46,7 +48,7 @@ From the `Dashboard`, go to `Mails` to land on the `Broadcasts` hub. Here, you w - 3. **Settings Pane**: The settings pane for the email and the selected block. - 4. **Exit Button**: The email editor exit button. -4. If you are not yet ready to send the email or schedule it, you can simply go back to the Broadcasts hub by clicking on the `Broadcasts` breadcrumb (located at the top of the page). +5. If you are not yet ready to send the email or schedule it, you can simply go back to the Broadcasts hub by clicking on the `Broadcasts` breadcrumb (located at the top of the page). ## Send Immediately @@ -71,6 +73,7 @@ Once an email is scheduled, you will see the time it will be sent at the bottom, Now that you understand how to send broadcasts, you can also see: - [Set up automated email sequences](/en/email-marketing/sequences) +- [Create re-usable templates](/en/email-marketing/templates) - [Track your email performance with analytics](/en/email-marketing/analytics) ## 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 d6d75a86d..9259ed01b 100644 --- a/apps/docs/src/pages/en/email-marketing/sequences.md +++ b/apps/docs/src/pages/en/email-marketing/sequences.md @@ -1,5 +1,5 @@ --- -title: Email sequences +title: Sequences description: Send email sequences to your audience layout: ../../../layouts/MainLayout.astro --- @@ -26,34 +26,36 @@ Here, you will see all the sequences you have configured. You will be redirected to the sequence compose screen. The active tab will be `Compose`. -2. Let's get acquainted with the interface. +2. You will see a template selection screen. Click the template you'd like to use as a starting point. After selecting it, You will be redirected to the sequence management screen. The active tab will be `Compose`. + +3. 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. - - 3. **Entrance Condition**: The condition that triggers this sequence for a user. You can pick from the following conditions: - - `Tag added` - - `Tag removed` - - `Product purchased` - - `Subscriber added` - - `Community joined` - - `Community left` - - 4. **Entrance Condition Data**: The exact tag or product that triggers the sequence. This field is only relevant in the context of the `Entrance Condition` field. - - 5. **Save**: A button to save your changes to the sequence. - - 6. **Start/Pause**: A button to start or pause the sequence. Once paused, the sequence won't be triggered for subsequent events in the system. - - 7. **Email Row**: Shows an overview of one of the emails in the sequence. - - 8. **New Email Button**: A button to add a new email to the sequence. +![Sequence Compose](/assets/emails/compose-sequence.png) - ![Sequence Compose](/assets/emails/compose-sequence.png) +- 1. **Sequence Name**: The internal name of the sequence. +- 2. **From**: The sender's name that is displayed in the emails sent. +- 3. **Entrance Condition**: The condition that triggers this sequence for a user. You can pick from the following conditions: + - `Tag added` + - `Tag removed` + - `Product purchased` + - `Subscriber added` + - `Community joined` + - `Community left` +- 4. **Entrance Condition Data**: The exact tag or product that triggers the sequence. This field is only relevant in the context of the `Entrance Condition` field. +- 5. **Save**: A button to save your changes to the sequence. +- 6. **Start/Pause**: A button to start or pause the sequence. Once paused, the sequence won't be triggered for subsequent events in the system. +- 7. **Email Row**: Shows an overview of one of the emails in the sequence. +- 8. **New Email Button**: A button to add a new email to the sequence. -3. Fill in the details for `Sequence Name`, `From`, `Entrance Condition`, and `Entrance Condition Data` (if applicable), then hit `Save`. +4. Fill in the details for `Sequence Name`, `From`, `Entrance Condition`, and `Entrance Condition Data` (if applicable), then hit `Save`. -4. Start adding emails to this sequence. When you create a new sequence, an empty email is added to it by default. +5. 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: +6. Let's understand what information an email row shows: ![Sequence email row](/assets/emails/compose-sequence-email-row.jpeg) @@ -64,9 +66,9 @@ In the following image, we have marked all the sections. To see the description > 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 an email, click on the subject. This will open the email compose screen as shown below. +7. 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: +8. Let's get acquainted with the email compose interface: - 1. **Delay**: The delay (in days) between this email and the previous one. - 2. **Subject**: The email's subject. @@ -78,8 +80,8 @@ In the following image, we have marked all the sections. To see the description ![Sequence's Email Compose](/assets/emails/compose-sequence-email.png) -8. Edit the email's subject and status, then hit `Save`. -9. Edit the email's content by clicking on the mail edit button. Upon clicking the **Mail Edit** button, a full-page email editor will open where you can edit the email. +9. Edit the email's subject and status, then hit `Save`. +10. Edit the email's content by clicking on the mail edit button. Upon clicking the **Mail Edit** button, a full-page email editor will open where you can edit the email. > When done, simply press the exit button. All changes are auto-saved. @@ -92,18 +94,19 @@ In the following image, we have marked all the sections. To see the description - 3. **Settings Pane**: The settings pane for the email and the selected block. - 4. **Exit Button**: The email editor exit button. -10. To go back to the sequence settings, click on the `Compose sequence` breadcrumb as shown below. +11. To go back to the sequence settings, click on the `Compose sequence` breadcrumb as shown below. ![Go Back to Sequence Settings](/assets/emails/back-to-sequence-breadcrumb.png) -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. +12. Add more emails to the sequence by clicking on the `New email` button. +13. 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 Steps Now that you understand how to create email sequences, you can also see: - [Send one-off broadcasts](/en/email-marketing/broadcasts) +- [Create re-usable templates](/en/email-marketing/templates) - [Track your email performance with analytics](/en/email-marketing/analytics) ## Stuck Somewhere? diff --git a/apps/docs/src/pages/en/email-marketing/templates.md b/apps/docs/src/pages/en/email-marketing/templates.md new file mode 100644 index 000000000..699fbb347 --- /dev/null +++ b/apps/docs/src/pages/en/email-marketing/templates.md @@ -0,0 +1,99 @@ +--- +title: Templates +description: Create reusable email templates for broadcasts and sequences +layout: ../../../layouts/MainLayout.astro +--- + +Email templates help you build a reusable library of branded email layouts. Once saved, you can use them while creating broadcasts or sequence emails. + +> 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). + +## Templates Hub + +From the `Dashboard`, go to `Mails` and open the `Templates` tab. + +Here, you will see all templates you have saved. + +- If this is your first template, click `Create from template`. +- If you already have templates, click `New template`. + +![Templates Hub](/assets/emails/email-templates-hub.png) + +## Create a Template + +1. Open the `Templates` tab and click `Create from template` (or `New template`). +2. You will be taken to the `Choose a template` screen. + + ![Templates selection screen](/assets/emails/email-template-selection-screen.png) + +3. Review the available sections: + + - **System**: Built-in starter templates. + - **My templates**: Your saved templates. + +4. Click any template card in the `System` section to start from it. + +CourseLit will create a new custom template and open its `Manage` screen. + +## Edit a Template + +On the `Manage` screen, you can control the template settings and content. + +![Template management](/assets/emails/email-template-manage-screen.png) + +### Edit title + +1. Update the `Template name`. +2. Click `Save` to persist the new name. + +### Edit content + +1. Click the edit button on the preview (pencil icon) to open the full email editor. + + ![Template edit button](/assets/emails/email-template-manage-edit-button.png) + +2. Edit blocks, styles, and variables in the editor. + + ![Email editor invoked via template manage screen](/assets/emails/email-editor-via-template.png) + +> Template content is auto-saved while editing. + +3. Click `Exit` to return to the template `Manage` screen. + +## Use Templates in Broadcasts and Sequences + +You can use templates while creating emails: + +1. Go to `Mails`. +2. Click `New broadcast` or `New sequence`. +3. On the `Choose a template` page, select one from: + + - `System` (starter templates) + - `My templates` (your saved templates) + +4. Continue editing subject, audience/trigger details, and send settings as needed. + +You can also add a template to an existing sequence by creating a new email in that sequence and selecting a template. + +## Delete a Template + +1. Open `Mails` → `Templates`. +2. Open the template you want to remove. +3. In `Danger zone`, click `Delete template`. +4. Confirm deletion in the dialog. + +> Template deletion is permanent and cannot be undone. + +## Next Steps + +Now that you know how templates work, you can: + +- [Send one-off broadcasts](/en/email-marketing/broadcasts) +- [Set up automated email sequences](/en/email-marketing/sequences) +- [Track your email performance with analytics](/en/email-marketing/analytics) + +## 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/website/blocks.md b/apps/docs/src/pages/en/website/blocks.md index 78ced6dfe..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. - ![Header edit link](/assets/pages/header-edit-link.png) -
+![Header edit link](/assets/pages/header-edit-link.png) + ### [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. - ![Create a hyperlink in rich text block](/assets/pages/courselit-text-editor-create-links.gif) -
+![Create a hyperlink in rich text block](/assets/pages/courselit-text-editor-create-links.gif) + ### [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. @@ -322,7 +322,7 @@ In the `Design` panel, you can customize: - Maximum width - Vertical padding - Social media links (Facebook, Twitter, Instagram, LinkedIn, YouTube, Discord, GitHub) -
+
## [Shared blocks](#shared-blocks) diff --git a/apps/docs/src/pages/en/website/themes.md b/apps/docs/src/pages/en/website/themes.md index 2d7bed189..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)/(with-layout)/login/login-form.tsx b/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx index a58865f0e..3d7268023 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 @@ -142,6 +142,13 @@ export default function LoginForm({ await getUserProfile(address.backend), ); } + } 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); } 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 ( +
+
+