diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 000000000..e5b6d8d6a --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,8 @@ +# Changesets + +Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works +with multi-package repos, or single-package repos to help you version and publish your code. You can +find the full documentation for it [in our repository](https://github.com/changesets/changesets) + +We have a quick list of common questions to get you started engaging with this project in +[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 000000000..9ab41124f --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [ + "@courselit/web", + "@courselit/state-management", + "@courselit/utils", + "@courselit/queue", + "@courselit/icons", + "@courselit/components-library", + "tsconfig", + "tailwind-config", + "@courselit/page-blocks", + "@courselit/text-editor", + "@courselit/common-logic", + "@courselit/page-primitives", + "@courselit/common-models", + "@courselit/docs" + ] +} diff --git a/.github/workflows/publish-docker-images.yaml b/.github/workflows/publish-docker-images.yaml index 126546c9f..e48572cf2 100644 --- a/.github/workflows/publish-docker-images.yaml +++ b/.github/workflows/publish-docker-images.yaml @@ -6,60 +6,7 @@ on: - '*' jobs: -# publish-packages: -# runs-on: ubuntu-latest -# steps: -# - name: checkout -# uses: actions/checkout@v1 - -# - name: Configure CI Git User -# run: | -# git config --global user.name 'Rajat Saxena' -# git config --global user.email 'hi@sub.rajatsaxena.dev' -# git remote set-url origin https://$GITHUB_ACTOR:$GITHUB_PAT@github.com/codelitdev/courselit -# env: -# GITHUB_PAT: ${{ secrets.PAT }} - -# - name: Checkout and pull branch -# run: | -# LATEST_TAG=$(git describe --tags `git rev-list --tags --max-count=1`) -# git checkout $LATEST_TAG - -# - name: Setup pnpm -# uses: pnpm/action-setup@v2 -# with: -# version: 8 - -# - name: Install Packages -# run: pnpm install - -# - name: Authenticate with Registry -# run: | -# echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc -# pnpm whoami -# env: -# NPM_TOKEN: ${{ secrets.NPM }} - -# - name: Build packages -# run: | -# pnpm --filter @courselit/icons build -# 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/common-widgets build - -# - name: Publish package -# run: | -# pnpm publish -# env: -# GH_TOKEN: ${{ secrets.PAT }} -# GITHUB_TOKEN: ${{ secrets.PAT }} -# NPM_TOKEN: ${{ secrets.NPM }} - publish-docker-images: - # needs: publish-packages runs-on: ubuntu-latest steps: - name: checkout diff --git a/.github/workflows/publish-packages.yaml b/.github/workflows/publish-packages.yaml new file mode 100644 index 000000000..0059349a6 --- /dev/null +++ b/.github/workflows/publish-packages.yaml @@ -0,0 +1,62 @@ +name: Publish NPM packages + +on: + push: + branches: + - main + tags-ignore: + - '**' + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +env: + CI: true + +jobs: + publish-packages: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v1 + + - name: Configure CI Git User + run: | + git config --global user.name 'Rajat Saxena' + git config --global user.email 'hi@sub.rajatsaxena.dev' + git remote set-url origin https://$GITHUB_ACTOR:$GITHUB_PAT@github.com/codelitdev/courselit + env: + GITHUB_PAT: ${{ secrets.PAT }} + + - name: Checkout and pull branch + run: | + git checkout ${{ github.ref_name }} + git pull origin ${{ github.ref_name }} + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 8 + + - name: Install Packages + run: pnpm install + + - name: Authenticate with Registry + run: | + echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc + pnpm whoami + env: + NPM_TOKEN: ${{ secrets.NPM }} + + - name: Create and publish versions + id: changesets + uses: changesets/action@v1 + with: + commit: "chore: update versions" + title: "chore: update versions" + publish: pnpm ci:publish + env: + GITHUB_TOKEN: ${{ secrets.PAT }} + NPM_TOKEN: ${{ secrets.NPM }} + + - name: Echo changeset output + run: echo "${{ steps.changesets.outputs.hasChangesets }}" \ No newline at end of file diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index d64688860..6ff231158 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -17,6 +17,7 @@ jobs: run: | pnpm --filter @courselit/icons build pnpm --filter @courselit/page-models build + pnpm --filter @courselit/email-editor build pnpm --filter @courselit/common-models build pnpm --filter @courselit/utils build pnpm --filter @courselit/text-editor build diff --git a/.gitignore b/.gitignore index 2c85a6c61..13974cbeb 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,6 @@ report*.json .DS_Store # Dev tools files -.eslintcache \ No newline at end of file +.eslintcache + +.npmrc \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit index 4473e630f..770b781fb 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,5 +1,4 @@ #!/usr/bin/env sh . "$(dirname "$0")/_/husky.sh" -pnpm exec lint-staged -pnpm test \ No newline at end of file +pnpm exec lint-staged \ No newline at end of file diff --git a/apps/docs/public/assets/emails/back-to-sequence-breadcrumb.jpeg b/apps/docs/public/assets/emails/back-to-sequence-breadcrumb.jpeg deleted file mode 100644 index 42e3125a0..000000000 Binary files a/apps/docs/public/assets/emails/back-to-sequence-breadcrumb.jpeg and /dev/null 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 new file mode 100644 index 000000000..ad5e029d4 Binary files /dev/null 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 new file mode 100644 index 000000000..95f081fb4 Binary files /dev/null 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 new file mode 100644 index 000000000..6a9cbc22c Binary files /dev/null and b/apps/docs/public/assets/emails/compose-broadcast.png differ diff --git a/apps/docs/public/assets/emails/compose-sequence-email.png b/apps/docs/public/assets/emails/compose-sequence-email.png index eeaef198a..92f93b22d 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 d2adb7566..54e77ebee 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 new file mode 100644 index 000000000..154232c9b Binary files /dev/null and b/apps/docs/public/assets/emails/email-editor.png differ diff --git a/apps/docs/public/assets/emails/sequences-hub.png b/apps/docs/public/assets/emails/sequences-hub.png index fb8047646..56183b4ab 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 058bf4834..00843b872 100644 --- a/apps/docs/src/config.ts +++ b/apps/docs/src/config.ts @@ -86,8 +86,8 @@ export const SIDEBAR: Sidebar = { link: "en/email-marketing/mail-access-request", }, { - text: "Broadcast mails", - link: "en/email-marketing/broadcast-mails", + text: "Broadcasts", + link: "en/email-marketing/broadcasts", }, { text: "Sequences (Campaigns)", diff --git a/apps/docs/src/pages/en/email-marketing/broadcast-mails.md b/apps/docs/src/pages/en/email-marketing/broadcast-mails.md deleted file mode 100644 index 49f7e06e9..000000000 --- a/apps/docs/src/pages/en/email-marketing/broadcast-mails.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: Broadcast mails -description: Send one-off emails to your audience -layout: ../../../layouts/MainLayout.astro ---- - -Broadcast emails are typically sent to your entire list or specific segments simultaneously, making them ideal for announcements, promotions, or updates. - -> The feature is currently in beta, which means you may encounter bugs. Please report them in our Discord group if you run into any. - -> **Before you start**: If your school is hosted on [courselit.app](https://courselit.app), you need to get approved for sending marketing emails. [Request access here](/en/email-marketing/mail-access-request). - -## Broadcasts Hub - -From the `Dashboard`, go to `Mails` to land on the `Broadcasts` hub. Here you will see all the broadcasts you have ever worked on. - -![Broadcasts Hub](/assets/emails/broadcasts-hub.jpeg) - -## Compose Your Email - -1. Click on 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 demarcated all the sections. To see the description of a section, notice 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. - - 3. **Subject**: The email subject goes here. - - 4. **Variables**: [Liquid](https://liquidjs.com/) templating variables that are available for you to use in your email body. - - 5. **Email Compose Window**: Email content goes here. - - 6. **Email Preview**: Live email preview. - > During the preview, the Liquid variables will be displayed as placeholders. The actual values will be replaced when sending the actual mail. - - 7. **Send Button**: Sends the email immediately. - - 8. **Schedule Button**: Lets you schedule an email for later. - - ![Broadcast Compose](/assets/emails/compose-broadcast.jpeg) - -3. Compose your email. - -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 `Mails` breadcrumb (located at the top of the page). - - > Your changes are saved in real time, so you won't lose anything. You can always come back to your draft emails. - -## Send Immediately - -Once your email is ready, you can either send it right away or schedule it for later. Click on the `Send` button to send the email immediately. - -## Schedule for Later - -Click on the `Schedule` button to see an additional input box to enter the date and time to send the email, as shown below. The time you select here is based on your own time zone. - -> In the background, all dates and times are converted into UTC. - -![Schedule Broadcast Mail](/assets/emails/schedule.jpeg) - -### Canceling a Scheduled Email - -Once an email is scheduled, you will see the time it will be sent at the bottom, as shown below. Simply click on the `Cancel sending` button to cancel the scheduled send. - -![Cancel Scheduled Mail](/assets/emails/scheduled-mail.jpeg) - -## Next Step - -Let's see how to send automated email campaigns (aka sequences) when something happens in your school. [Click here](/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 new file mode 100644 index 000000000..4092c90c5 --- /dev/null +++ b/apps/docs/src/pages/en/email-marketing/broadcasts.md @@ -0,0 +1,75 @@ +--- +title: Broadcasts +description: Send one-off emails to your audience +layout: ../../../layouts/MainLayout.astro +--- + +Broadcast emails are typically sent to your entire list or specific segments simultaneously, making them ideal for announcements, promotions, or updates. + +> 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). + +## Broadcasts Hub + +From the `Dashboard`, go to `Mails` to land on the `Broadcasts` hub. Here, you will see all the broadcasts you have ever worked on. + +![Broadcasts Hub](/assets/emails/broadcasts-hub.png) + +## Compose Your Email + +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. + + - 1. **User Filters**: To select the users. + - 2. **Total Selected Users**: The total number of selected users as per the applied filters. + - 3. **Subject**: The email subject goes here. + - 4. **Email Preview**: Live email preview. + > During the preview, variables will be displayed as placeholders. The actual values will be replaced when sending the actual email. + - 5. **Mail Edit Button**: Opens the mail for editing. + - 6. **Send Button**: Sends the email immediately. + - 7. **Schedule Button**: Lets you schedule an email for later. + + ![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. + + > When done, simply press the exit button. All changes are auto-saved. + + ![Email editor](/assets/emails/email-editor.png) + + We have annotated the screenshot of the CourseLit email editor: + + - 1. **Variables**: You can use these variables in your emails. These variables will be replaced with the actual data when sending the email. + - 2. **Email Preview**: The live preview of the email. + - 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). + +## Send Immediately + +Once your email is ready, you can either send it right away or schedule it for later. Click the `Send` button to send the email immediately. + +## Schedule for Later + +Click the `Schedule` button to see an additional input box to enter the date and time to send the email, as shown below. The time you select here is based on your own time zone. + +> In the background, all dates and times are converted to UTC. + +![Schedule Broadcast Mail](/assets/emails/schedule.jpeg) + +### Canceling a Scheduled Email + +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) + +## Next Step + +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). + +## 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/introduction.md b/apps/docs/src/pages/en/email-marketing/introduction.md index d097e6351..51428e66a 100644 --- a/apps/docs/src/pages/en/email-marketing/introduction.md +++ b/apps/docs/src/pages/en/email-marketing/introduction.md @@ -4,15 +4,15 @@ description: Send broadcasts and email sequences using CourseLit layout: ../../../layouts/MainLayout.astro --- -Sending timely emails to your audience keeps them engaged, which is why CourseLit includes a seamless integration solution for email marketing and automation. +Sending timely emails to your audience keeps them engaged, which is why CourseLit includes a seamless solution for email marketing and automation. -> The feature is currently in beta, which means you may encounter bugs. Please report them in our Discord group if you run into any. +> 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. ## Mailing Capabilities -CourseLit offers two types of mails. +CourseLit offers two types of emails: -#### 1. [Broadcasts](/en/email-marketing/broadcast-mails) +#### 1. [Broadcasts](/en/email-marketing/broadcasts) Send one-off emails to selected recipients for quick updates or newsletters. @@ -20,10 +20,10 @@ 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 Step -Learn [how to send broadcast emails](/en/email-marketing/broadcast-mails) to your audience. +Learn [how to send broadcast emails](/en/email-marketing/broadcasts) to your audience. -## Stuck somewhere? +## Stuck Somewhere? -We are always here for you. Come chat with us in our Discord channel or send a tweet at @CourseLit. +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/mail-access-request.md b/apps/docs/src/pages/en/email-marketing/mail-access-request.md index ffae078b0..d525f030c 100644 --- a/apps/docs/src/pages/en/email-marketing/mail-access-request.md +++ b/apps/docs/src/pages/en/email-marketing/mail-access-request.md @@ -32,7 +32,7 @@ Fill out the form and hit `Save`. ## Next step -Let's see [how to send broadcast emails](/en/email-marketing/broadcast-mails). +Let's see [how to send broadcast emails](/en/email-marketing/broadcasts). ## 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 5e0ea55fb..4e1cb2f3c 100644 --- a/apps/docs/src/pages/en/email-marketing/sequences.md +++ b/apps/docs/src/pages/en/email-marketing/sequences.md @@ -4,30 +4,30 @@ description: Send email sequences to your audience layout: ../../../layouts/MainLayout.astro --- -An email sequence (aka campaign) is a series of pre-scheduled emails designed to guide recipients through a specific journey, whether it's welcoming new subscribers, promoting a product, or re-engaging inactive customers. +An email sequence (also known as a campaign) is a series of pre-scheduled emails designed to guide recipients through a specific journey, whether it's welcoming new subscribers, promoting a product, or re-engaging inactive customers. By strategically planning each email, you can provide valuable content, build trust, and encourage actions that align with your business goals. -> The feature is currently in beta, which means you may encounter bugs. Please report them in our Discord group if you run into any. +> 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 for sending marketing emails. [Request access here](/en/email-marketing/mail-access-request). +> **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). ## Sequences Hub From the `Dashboard`, go to `Mails` to land on the `Broadcasts` hub. The `Sequences` tab is located to the right of it. -Here you will see all the sequences you have configured. +Here, you will see all the sequences you have configured. ![Broadcasts Hub](/assets/emails/sequences-hub.png) ## Compose a Sequence -1. Click on the `New sequence` button on the right, in the `Sequences` hub. +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 demarcated all the sections. To see the description of a section, notice its number in the screenshot and find its description below. +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**: Internal name of the sequence. - - 2. **From**: The sender's name that gets displayed in the emails sent. + - 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` @@ -37,23 +37,23 @@ Here you will see all the sequences you have configured. - `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 triggers occurring in the system. + - 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) -3. Fill in the details for `Sequence Name`, `From`, `Entrance Condition`, and `Entrance Condition Data` (if applicable) and hit `Save`. +3. Fill in the details for `Sequence Name`, `From`, `Entrance Condition`, and `Entrance Condition Data` (if applicable), then hit `Save`. -4. Let's start adding mails to this sequence. When you create a new sequence, an empty email is added to it by default. +4. Start adding emails to this sequence. When you create a new sequence, an empty email is added to it by default. 5. Let's understand what information an email row shows: - 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**: Houses options like `Delete`, etc. + - 3. **Context Menu**: Contains options like `Delete`, etc. - > The default email has `0 days` as the delay, which signifies that the email should wait for 0 days since the last sent email. Since this is the first email in the sequence, it will be sent right away as soon as a user enters the sequence. + > 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. @@ -61,23 +61,34 @@ Here you will see all the sequences you have configured. - 1. **Delay**: The delay (in days) between this email and the previous one. - 2. **Subject**: The email's subject. - - 3. **Preview Text**: The preview text that gets displayed in email inboxes. - - 4. **Status**: Only `Published` emails are sent to the users. If you want to skip any email without deleting it from the sequence, just switch its status to `Unpublished`. - - 5. **Variables**: [Liquid](https://liquidjs.com/) templating variables that are available for you to use in your email body. - - 6. **Email Compose Window**: Email content goes here. - - 7. **Email Preview**: Live email preview. - > During the preview, the Liquid variables will be displayed as placeholders. The actual values will be replaced when sending the actual mail. - - 8. **Save Button**: A button to save changes to the email. + - 3. **Email Preview**: Live email preview. + > During the preview, variables will be displayed as placeholders. The actual values will be replaced when sending the actual email. + - 4. **Status**: Only `Published` emails are sent to users. If you want to skip any email without deleting it from the sequence, just switch its status to `Unpublished`. + - 5. **Mail Edit Button**: Opens the mail for editing. + - 6. **Save Button**: A button to save changes to the email's details like subject, status, etc. ![Sequence's Email Compose](/assets/emails/compose-sequence-email.png) -8. Compose the email and hit `Save`. -9. To go back to the sequence settings, click on the `Compose sequence` breadcrumb as shown below. +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. - ![Go Back to Sequence Settings](/assets/emails/back-to-sequence-breadcrumb.jpeg) + > When done, simply press the exit button. All changes are auto-saved. -10. Add more emails to the sequence by clicking on the `New email` button. -11. Keep editing your sequence until you think it's perfect. Once you are satisfied with your sequence, hit the `Start` button to start sending this sequence to the users. + ![Email editor](/assets/emails/email-editor.png) + + We have annotated the screenshot of the CourseLit email editor: + + - 1. **Variables**: You can use these variables in your emails. These variables will be replaced with the actual data when sending the email. + - 2. **Email Preview**: The live preview of the email. + - 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. + + ![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. ## Next Step diff --git a/apps/docs/src/pages/en/products/section.md b/apps/docs/src/pages/en/products/section.md index 1f6895f5d..dc1cf3385 100644 --- a/apps/docs/src/pages/en/products/section.md +++ b/apps/docs/src/pages/en/products/section.md @@ -73,7 +73,7 @@ If drip configuration is enabled for a section, a student won't be able to acces ### Notify Users When a Section Has Dripped 1. Click on the `Send email notification` checkbox. -2. Compose your email. Learn more about the email composition interface [here](/en/email-marketing/broadcast-mails#compose-your-email). +2. Compose your email. Learn more about the email composition interface [here](/en/email-marketing/broadcasts#compose-your-email). 3. Click `Continue` to save it. ![Drip Notification](/assets/products/drip-notify-email.jpeg) diff --git a/apps/queue/README.md b/apps/queue/README.md index 4c82ca535..4e3f8eaab 100644 --- a/apps/queue/README.md +++ b/apps/queue/README.md @@ -2,6 +2,29 @@ This app delivers the mails. +## Environment Variables + +The following environment variables are used by the queue service: + +### Required Variables + +- `DB_CONNECTION_STRING` - MongoDB connection string for database and logging +- `EMAIL_HOST` - SMTP server hostname for sending emails +- `EMAIL_USER` - SMTP authentication username +- `EMAIL_PASS` - SMTP authentication password +- `COURSELIT_JWT_SECRET` - JWT secret for authentication middleware + +### Optional Variables + +- `REDIS_HOST` - Redis server hostname (default: `localhost`) +- `REDIS_PORT` - Redis server port (default: `6379`) +- `EMAIL_PORT` - SMTP server port (default: `587`) +- `PORT` - HTTP server port (default: `80`) +- `NODE_ENV` - Environment mode. When set to `production`, emails are actually sent; otherwise they are only logged +- `SEQUENCE_BOUNCE_LIMIT` - Maximum number of bounces allowed for email sequences (default: `3`) +- `PROTOCOL` - Protocol used for generating site URLs (default: `https`) +- `DOMAIN` - Base domain name for generating site URLs + ## Running the app 1. Create a file called `.env.local` with the appropriate environment variables. diff --git a/apps/queue/package.json b/apps/queue/package.json index 4c327208e..cb60fb4c1 100644 --- a/apps/queue/package.json +++ b/apps/queue/package.json @@ -14,6 +14,7 @@ "dependencies": { "@courselit/common-logic": "workspace:^", "@courselit/common-models": "workspace:^", + "@courselit/email-editor": "workspace:^", "@courselit/utils": "workspace:^", "bullmq": "^4.14.0", "express": "^4.18.2", diff --git a/apps/queue/src/domain/model/course.ts b/apps/queue/src/domain/model/course.ts index ea0110c2c..90deaf77d 100644 --- a/apps/queue/src/domain/model/course.ts +++ b/apps/queue/src/domain/model/course.ts @@ -1,65 +1,4 @@ -import { Constants, Group } from "@courselit/common-models"; import mongoose from "mongoose"; -import EmailSchema from "./email"; - -export interface Course { - domain: mongoose.Types.ObjectId; - courseId: string; - title: string; - slug: string; - creatorId: string; - creatorName: string; - published: boolean; - isBlog: boolean; - isFeatured: boolean; - lessons: any[]; - description?: string; - groups: Group[]; - sales: number; - customers: string[]; - pageId?: string; -} - -const CourseSchema = new mongoose.Schema( - { - domain: { type: mongoose.Schema.Types.ObjectId, required: true }, - courseId: { type: String, required: true }, - title: { type: String, required: true }, - slug: { type: String, required: true }, - creatorId: { type: String, required: true }, - creatorName: { type: String }, - published: { type: Boolean, required: true, default: false }, - lessons: [String], - description: String, - groups: [ - { - name: { type: String, required: true }, - _id: { - type: String, - required: true, - }, - rank: { type: Number, required: true }, - collapsed: { type: Boolean, required: true, default: true }, - lessonsOrder: { type: [String] }, - drip: new mongoose.Schema({ - type: { - type: String, - required: true, - enum: Constants.dripType, - }, - status: { type: Boolean, required: true, default: false }, - delayInMillis: { type: Number }, - dateInUTC: { type: Number }, - email: EmailSchema, - }), - }, - ], - customers: [String], - pageId: { type: String }, - }, - { - timestamps: true, - }, -); +import { CourseSchema } from "@courselit/common-logic"; export default mongoose.models.Domain || mongoose.model("Course", CourseSchema); diff --git a/apps/queue/src/domain/model/email-template.ts b/apps/queue/src/domain/model/email-template.ts index 52cb039b4..765087920 100644 --- a/apps/queue/src/domain/model/email-template.ts +++ b/apps/queue/src/domain/model/email-template.ts @@ -1,5 +1,6 @@ import mongoose from "mongoose"; import { EmailTemplate as PublicEmailTemplate } from "@courselit/common-models"; +import { EmailContentSchema } from "@courselit/common-logic"; interface EmailTemplate extends PublicEmailTemplate { domain: mongoose.Schema.Types.ObjectId; @@ -11,7 +12,7 @@ const EmailTemplateSchema = new mongoose.Schema({ templateId: { type: String, required: true }, title: { type: String, required: true }, creatorId: { type: String, required: true }, - content: { type: String, required: true }, + content: { type: EmailContentSchema, required: true }, }); EmailTemplateSchema.index( diff --git a/apps/queue/src/domain/model/email.ts b/apps/queue/src/domain/model/email.ts index e10077fdd..a6b266d0e 100644 --- a/apps/queue/src/domain/model/email.ts +++ b/apps/queue/src/domain/model/email.ts @@ -1,20 +1,4 @@ import mongoose from "mongoose"; -import { Email, Constants } from "@courselit/common-models"; +import { EmailSchema } from "@courselit/common-logic"; -const EmailSchema = new mongoose.Schema({ - emailId: { type: String, required: true }, - templateId: { type: String }, - content: { type: String, required: true }, - subject: { type: String, required: true }, - delayInMillis: { type: Number, required: true, default: 86400000 }, - published: { type: Boolean, required: true, default: false }, - action: new mongoose.Schema({ - type: { - type: String, - enum: Constants.emailActionTypes, - }, - data: { type: mongoose.Schema.Types.Mixed }, - }), -}); - -export default EmailSchema; +export default mongoose.models.Email || mongoose.model("Email", EmailSchema); diff --git a/apps/queue/src/domain/process-drip.ts b/apps/queue/src/domain/process-drip.ts index d1300de53..f6d0f4909 100644 --- a/apps/queue/src/domain/process-drip.ts +++ b/apps/queue/src/domain/process-drip.ts @@ -1,11 +1,14 @@ -import CourseModel, { Course } from "./model/course"; +import CourseModel from "./model/course"; import UserModel from "./model/user"; import mailQueue from "./queue"; import { Liquid } from "liquidjs"; -import { getMemberships } from "./queries"; +import { getDomain, getMemberships } from "./queries"; import { Constants } from "@courselit/common-models"; -import { InternalUser } from "@courselit/common-logic"; +import { InternalCourse, InternalUser } from "@courselit/common-logic"; import { FilterQuery, UpdateQuery } from "mongoose"; +import { renderEmailToHtml } from "@courselit/email-editor"; +import { getSiteUrl } from "../utils/get-site-url"; +import { getUnsubLink } from "../utils/get-unsub-link"; const liquidEngine = new Liquid(); export async function processDrip() { @@ -16,13 +19,13 @@ export async function processDrip() { `Starting process of drips at ${new Date().toDateString()}`, ); - const courseQuery: FilterQuery = { + const courseQuery: FilterQuery = { "groups.drip": { $exists: true }, }; // @ts-ignore - Mongoose type compatibility issue const courses = (await CourseModel.find( courseQuery, - ).lean()) as unknown as Course[]; + ).lean()) as unknown as InternalCourse[]; const nowUTC = new Date().getTime(); @@ -77,7 +80,7 @@ export async function processDrip() { group.drip.delayInMillis >= 0 && nowUTC >= lastDripAtUTC + group.drip.delayInMillis, ) - .map((group) => group.id); + .map((group) => (group as any)._id); const allAccessibleGroupIds = [ ...new Set([ @@ -108,20 +111,33 @@ export async function processDrip() { await UserModel.updateOne(updateQuery, updateData); const firstGroupWithDripEmailSet = course.groups.find( - (group) => group.id === newGroupIds[0], + (group) => (group as any)._id === newGroupIds[0], ); if (firstGroupWithDripEmailSet) { + const domain = await getDomain(course.domain); const templatePayload = { subscriber: { email: user.email, name: user.name, tags: user.tags, }, + product: { + title: course.title, + url: `${getSiteUrl(domain)}/course/${course.slug}/${course.courseId}`, + }, + address: domain.settings.mailingAddress, + unsubscribe_link: getUnsubLink( + domain, + user.unsubscribeToken, + ), }; if (firstGroupWithDripEmailSet.drip?.email?.content) { const content = await liquidEngine.parseAndRender( - firstGroupWithDripEmailSet.drip.email.content, + await renderEmailToHtml({ + email: firstGroupWithDripEmailSet.drip.email + .content, + }), templatePayload, ); await mailQueue.add("mail", { diff --git a/apps/queue/src/domain/process-ongoing-sequences.ts b/apps/queue/src/domain/process-ongoing-sequences.ts index 62d484ee0..d47ecd6eb 100644 --- a/apps/queue/src/domain/process-ongoing-sequences.ts +++ b/apps/queue/src/domain/process-ongoing-sequences.ts @@ -12,7 +12,6 @@ import { removeRuleForBroadcast, updateSequenceSentAt, getDomain, - getTemplate, } from "./queries"; import { sendMail } from "../mail"; import { Liquid } from "liquidjs"; @@ -21,6 +20,8 @@ import redis from "../redis"; import mongoose from "mongoose"; import sequenceQueue from "./sequence-queue"; import { AdminSequence, InternalUser } from "@courselit/common-logic"; +import { renderEmailToHtml } from "@courselit/email-editor"; +import { getUnsubLink } from "../utils/get-unsub-link"; const liquidEngine = new Liquid(); new Worker( @@ -179,11 +180,7 @@ async function attemptMailSending({ : `${creator.email} <${creator.email}>`; const to = user.email; const subject = email.subject; - const unsubscribeLink = `https://${ - domain.customDomain - ? `${domain.customDomain}` - : `${domain.name}.${process.env.DOMAIN}` - }/api/unsubscribe/${user.unsubscribeToken}`; + const unsubscribeLink = getUnsubLink(domain, user.unsubscribeToken); const templatePayload = { subscriber: { email: user.email, @@ -197,21 +194,12 @@ async function attemptMailSending({ return; } // const content = email.content; - let content = await liquidEngine.parseAndRender( - email.content, + const content = await liquidEngine.parseAndRender( + await renderEmailToHtml({ + email: email.content, + }), templatePayload, ); - if (email.templateId) { - const template = await getTemplate(email.templateId); - if (template) { - content = await liquidEngine.parseAndRender( - template.content, - Object.assign({}, templatePayload, { - content: content, - }), - ); - } - } try { await sendMail({ from, diff --git a/apps/queue/src/domain/queries.ts b/apps/queue/src/domain/queries.ts index 58c7a11a9..2f1fbd991 100644 --- a/apps/queue/src/domain/queries.ts +++ b/apps/queue/src/domain/queries.ts @@ -62,7 +62,7 @@ export async function updateSequenceSentAt(sequenceId: string): Promise { ); } -export async function getDomain(id: mongoose.Schema.Types.ObjectId) { +export async function getDomain(id: mongoose.Types.ObjectId) { // @ts-ignore - Mongoose type compatibility issue return await DomainModel.findById(id); } diff --git a/apps/queue/src/index.ts b/apps/queue/src/index.ts index 738b6e6b6..437e98a3d 100644 --- a/apps/queue/src/index.ts +++ b/apps/queue/src/index.ts @@ -1,7 +1,6 @@ import express from "express"; import jobRoutes from "./job/routes"; import sseRoutes from "./sse/routes"; -import { jwtUtils } from "@courselit/utils"; // start workers import "./domain/worker"; @@ -9,32 +8,11 @@ import "./workers/notifications"; // start loops import { startEmailAutomation } from "./start-email-automation"; -import { logger } from "./logger"; +import { verifyJWTMiddleware } from "./middlewares/verify-jwt"; const app = express(); app.use(express.json()); -const verifyJWTMiddleware = (req, res, next) => { - try { - const token = req.headers.authorization?.split(" ")[1]; - if (!token) { - return res.status(401).json({ error: "No token provided" }); - } - - const secret = process.env.COURSELIT_JWT_SECRET; - const decoded: any = jwtUtils.verifyToken(token, secret); - if (!decoded) { - return res.status(401).json({ error: "Invalid token" }); - } - - req.user = decoded.user; - next(); - } catch (err) { - logger.error(err); - return res.status(500).json({ error: err.message }); - } -}; - app.use("/job", verifyJWTMiddleware, jobRoutes); app.use("/sse", sseRoutes); diff --git a/apps/queue/src/middlewares/verify-jwt.ts b/apps/queue/src/middlewares/verify-jwt.ts new file mode 100644 index 000000000..3bec18973 --- /dev/null +++ b/apps/queue/src/middlewares/verify-jwt.ts @@ -0,0 +1,23 @@ +import { jwtUtils } from "@courselit/utils"; +import { logger } from "../logger"; + +export const verifyJWTMiddleware = (req, res, next) => { + try { + const token = req.headers.authorization?.split(" ")[1]; + if (!token) { + return res.status(401).json({ error: "No token provided" }); + } + + const secret = process.env.COURSELIT_JWT_SECRET; + const decoded: any = jwtUtils.verifyToken(token, secret); + if (!decoded) { + return res.status(401).json({ error: "Invalid token" }); + } + + req.user = decoded.user; + next(); + } catch (err) { + logger.error(err); + return res.status(500).json({ error: err.message }); + } +}; diff --git a/apps/queue/src/utils/get-site-url.ts b/apps/queue/src/utils/get-site-url.ts new file mode 100644 index 000000000..8e4dbe970 --- /dev/null +++ b/apps/queue/src/utils/get-site-url.ts @@ -0,0 +1,11 @@ +import { Domain } from "@courselit/common-models"; + +export function getSiteUrl(domain: Domain) { + return `${process.env.PROTOCOL || "https"}://${ + domain.customDomain + ? `${domain.customDomain}` + : process.env.MULTITENANT === "true" + ? `${domain.name}.${process.env.DOMAIN}` + : process.env.DOMAIN + }`; +} diff --git a/apps/queue/src/utils/get-unsub-link.ts b/apps/queue/src/utils/get-unsub-link.ts new file mode 100644 index 000000000..a55318787 --- /dev/null +++ b/apps/queue/src/utils/get-unsub-link.ts @@ -0,0 +1,6 @@ +import { Domain } from "@courselit/common-models"; +import { getSiteUrl } from "./get-site-url"; + +export function getUnsubLink(domain: Domain, unsubscribeToken: string) { + return `${getSiteUrl(domain)}/api/unsubscribe/${unsubscribeToken}`; +} diff --git a/apps/web/.migrations/13-07-25_17-54-migrate-emails-for-emails-editor.js b/apps/web/.migrations/13-07-25_17-54-migrate-emails-for-emails-editor.js new file mode 100644 index 000000000..df498e4c0 --- /dev/null +++ b/apps/web/.migrations/13-07-25_17-54-migrate-emails-for-emails-editor.js @@ -0,0 +1,242 @@ +import mongoose from "mongoose"; +import { nanoid } from "nanoid"; + +function generateUniqueId() { + return nanoid(); +} + +mongoose.connect(process.env.DB_CONNECTION_STRING, { + useNewUrlParser: true, + useUnifiedTopology: true, +}); + +const EmailContentBlockSchema = new mongoose.Schema({ + blockType: { type: String, required: true }, + settings: { type: Object, required: true, default: () => ({}) }, +}); + +const EmailStyleSchema = new mongoose.Schema({ + colors: { type: Object, required: true }, + typography: { type: Object, required: true }, + structure: { type: Object, required: true }, +}); + +const EmailMetaSchema = new mongoose.Schema({ + previewText: { type: String }, + utm: { type: Object }, +}); + +const EmailActionSchema = new mongoose.Schema({ + type: { type: String }, + data: { type: mongoose.Schema.Types.Mixed }, +}); + +export const EmailContentSchema = new mongoose.Schema({ + content: { type: [EmailContentBlockSchema], required: true }, + style: { type: EmailStyleSchema, required: true }, + meta: { type: EmailMetaSchema, required: true }, +}); + +export const EmailSchema = new mongoose.Schema({ + emailId: { type: String, required: true, default: generateUniqueId }, + content: { type: EmailContentSchema, required: true }, + subject: { type: String, required: true }, + delayInMillis: { type: Number, required: true, default: 86400000 }, + published: { type: Boolean, required: true, default: false }, + action: EmailActionSchema, +}); + +export const SequenceSchema = new mongoose.Schema( + { + domain: { type: mongoose.Schema.Types.ObjectId, required: true }, + sequenceId: { + type: String, + required: true, + }, + type: { type: String, required: true }, + emails: { type: Object, required: true }, + }, + { + timestamps: true, + }, +); + +const Sequence = mongoose.model("Sequence", SequenceSchema); + +const CourseSchema = new mongoose.Schema( + { + domain: { type: mongoose.Schema.Types.ObjectId, required: true }, + courseId: { type: String, required: true, default: generateUniqueId }, + title: { type: String, required: true }, + groups: [ + { + name: { type: String, required: true }, + _id: { + type: String, + required: true, + }, + rank: { type: Number, required: true }, + drip: new mongoose.Schema({ + type: { + type: String, + required: true, + }, + status: { type: Boolean, required: true, default: false }, + delayInMillis: { type: Number }, + dateInUTC: { type: Number }, + email: { type: Object }, + }), + }, + ], + }, + { + timestamps: true, + }, +); + +const Course = mongoose.model("Course", CourseSchema); + +const defaultEmailStyle = { + colors: { + background: "#ffffff", + foreground: "#000000", + border: "#e2e8f0", + accent: "#0284c7", + accentForeground: "#ffffff", + }, + typography: { + header: { + fontFamily: "Arial, sans-serif", + letterSpacing: "0px", + textTransform: "none", + textDecoration: "none", + }, + text: { + fontFamily: "Arial, sans-serif", + letterSpacing: "0px", + textTransform: "none", + textDecoration: "none", + }, + link: { + fontFamily: "Arial, sans-serif", + textDecoration: "underline", + letterSpacing: "0px", + textTransform: "none", + }, + }, + interactives: { + button: { + padding: { + x: "16px", + y: "8px", + }, + border: { + width: "0px", + radius: "4px", + style: "solid", + }, + }, + link: { + padding: { + x: "0px", + y: "0px", + }, + }, + }, + structure: { + page: { + background: "#ffffff", + foreground: "#000000", + width: "600px", + marginY: "20px", + borderWidth: "1px", + borderStyle: "solid", + borderRadius: "10px", + }, + section: { + padding: { + x: "24px", + y: "16px", + }, + }, + }, +}; + +const migrateSequenceEmail = async (sequence) => { + console.log( + `Migrating sequence: ${sequence.sequenceId} (${sequence.type})`, + ); + for (const email of sequence.emails) { + if (email.content?.style) { + continue; + } + console.log(`Migrating email: ${email.emailId} (${email.subject})`); + email.content = { + style: defaultEmailStyle, + meta: { + previewText: email.previewText || "", + }, + content: [ + { + blockType: "text", + settings: { + content: email.content, + }, + }, + ], + }; + sequence.markModified("emails"); + await sequence.save(); + } +}; + +const migrateSequenceEmails = async () => { + const sequences = await Sequence.find({}); + for (const sequence of sequences) { + try { + await migrateSequenceEmail(sequence); + } catch (error) { + console.error(`Error updating homepage for domain: ${page.domain}`); + console.error(error); + } + } +}; + +const migrateDripCourses = async () => { + const courses = await Course.find({}); + for (const course of courses) { + let courseModified = false; + for (const group of course.groups) { + if (group.drip?.email && !group.drip.email?.content?.style) { + console.log( + `Migrating drip on course: ${course.title} (${group.name})`, + ); + group.drip.email.content = { + style: defaultEmailStyle, + meta: { + previewText: group.drip.email.previewText || "", + }, + content: [ + { + blockType: "text", + settings: { + content: group.drip.email.content, + }, + }, + ], + }; + courseModified = true; + } + } + if (courseModified) { + course.markModified("groups"); + await course.save(); + } + } +}; + +(async () => { + await migrateSequenceEmails(); + await migrateDripCourses(); + mongoose.connection.close(); +})(); diff --git a/apps/web/app/(with-contexts)/(with-layout)/p/[id]/page.tsx b/apps/web/app/(with-contexts)/(with-layout)/p/[id]/page.tsx index f6a64302d..eb54bb9e1 100644 --- a/apps/web/app/(with-contexts)/(with-layout)/p/[id]/page.tsx +++ b/apps/web/app/(with-contexts)/(with-layout)/p/[id]/page.tsx @@ -4,6 +4,7 @@ import ClientSidePage from "./client-side-page"; import { headers } from "next/headers"; import type { Metadata, ResolvingMetadata } from "next"; import { Media } from "@courselit/common-models"; +import { notFound } from "next/navigation"; type Props = { params: { @@ -26,7 +27,13 @@ export async function generateMetadata( }; } - const title = page.title || page.pageData?.title || page.name; + if (!page) { + return { + title: "Page not found", + }; + } + + const title = page?.title || page.pageData?.title || page.name; const socialImage: Media | undefined = page.socialImage || (page.pageData?.featuredImage as Media) || @@ -76,6 +83,10 @@ export default async function Page({ params }: Props) { return null; } + if (!page) { + return notFound(); + } + return ( - // - // - // ); - if (!loading && totalPages === 0) { return ; } 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 c769de9d5..fbcacb989 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 @@ -1,10 +1,53 @@ "use client"; import DashboardContent from "@components/admin/dashboard-content"; -import BroadcastEditor from "@components/admin/mails/broadcast-editor"; +import { isDateInFuture } from "@/lib/utils"; import { AddressContext } from "@components/contexts"; -import { BROADCASTS, PAGE_HEADER_EDIT_MAIL } from "@ui-config/strings"; +import { BROADCASTS } from "@ui-config/strings"; import { useContext } from "react"; +import { PaperPlane, Clock } from "@courselit/icons"; +import { + Form, + FormField, + Dialog2, + useToast, +} from "@courselit/components-library"; +import { + ChangeEvent, + FormEvent, + useEffect, + useState, + useRef, + useCallback, + useMemo, +} from "react"; +import { + SequenceReport, + UserFilter, + UserFilterAggregator, + SequenceStatus, +} from "@courselit/common-models"; +import { + BTN_SCHEDULE, + BTN_SEND, + BUTTON_CANCEL_SCHEDULED_MAIL, + BUTTON_CANCEL_TEXT, + DIALOG_SEND_HEADER, + ERROR_DELAY_EMPTY, + TOAST_TITLE_ERROR, + ERROR_SUBJECT_EMPTY, + FORM_MAIL_SCHEDULE_TIME_LABEL, + MAIL_SUBJECT_PLACEHOLDER, + PAGE_HEADER_EDIT_MAIL, + TOAST_MAIL_SENT, + TOAST_TITLE_SUCCESS, +} from "@ui-config/strings"; +import { Email as EmailContent } from "@courselit/email-editor"; +import { useSequence } from "@/hooks/use-sequence"; +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"; const breadcrumbs = [ { label: BROADCASTS, href: "/dashboard/mails?tab=Broadcasts" }, @@ -20,10 +63,585 @@ export default function Page({ }) { const address = useContext(AddressContext); const { id } = params; + const { sequence, loading, error, loadSequence } = useSequence(); + const [filters, setFilters] = useState([]); + const [filtersAggregator, setFiltersAggregator] = + useState("or"); + const [subject, setSubject] = useState(""); + const [content, setContent] = useState(null); + const [delay, setDelay] = useState(0); + const [showScheduleInput, setShowScheduleInput] = useState(false); + const [emailId, setEmailId] = useState(); + // const [published, setPublished] = useState(true); + const [filteredUsersCount, setFilteredUsersCount] = useState(0); + const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); + const [report, setReport] = useState(); + const [status, setStatus] = useState(null); + + // Refs to track initial values and prevent saving during load + const initialValues = useRef({ + subject: "", + content: null as EmailContent | null, + delay: 0, + filters: [] as UserFilter[], + filtersAggregator: "or" as UserFilterAggregator, + }); + const isInitialLoad = useRef(true); + const saveTimeoutRef = useRef(); + + const { toast } = useToast(); + + const fetch = useGraphQLFetch(); + + // Load sequence on mount + useEffect(() => { + loadSequence(id); + }, [loadSequence, id]); + + // Update state when sequence data is loaded + useEffect(() => { + if (sequence && isInitialLoad.current) { + // Set initial values in ref + initialValues.current = { + subject: sequence.emails[0].subject, + content: sequence.emails[0].content, + delay: sequence.emails[0].delayInMillis, + filters: sequence.filter?.filters || [], + filtersAggregator: sequence.filter?.aggregator || "or", + }; + + // Update state + setSubject(sequence.emails[0].subject); + setContent(sequence.emails[0].content); + setDelay(sequence.emails[0].delayInMillis); + setEmailId(sequence.emails[0].emailId); + // setPublished(sequence.emails[0].published); + if (sequence.filter) { + setFilters(sequence.filter.filters); + setFiltersAggregator(sequence.filter.aggregator); + } + setReport(sequence.report); + setStatus(sequence.status); + + isInitialLoad.current = false; + } + }, [sequence]); + + // Handle error state + useEffect(() => { + if (error) { + toast({ + title: TOAST_TITLE_ERROR, + description: error, + variant: "destructive", + }); + } + }, [error, toast]); + + const debouncedSave = useCallback(async () => { + if (!emailId || isInitialLoad.current) { + return; + } + + // Check if values have actually changed + const hasChanged = + subject !== initialValues.current.subject || + content !== initialValues.current.content || + delay !== initialValues.current.delay || + JSON.stringify(filters) !== + JSON.stringify(initialValues.current.filters) || + filtersAggregator !== initialValues.current.filtersAggregator; + + if (!hasChanged) { + return; + } + + // Clear existing timeout + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + + // Set new timeout for debounced save + saveTimeoutRef.current = setTimeout(async () => { + const mutation = ` + mutation updateSequence( + $sequenceId: String!, + $emailId: String!, + $title: String, + $filter: String, + $content: String, + $delayInMillis: Float, + ) { + sequence: updateSequence( + sequenceId: $sequenceId, + title: $title, + filter: $filter, + ) { + sequenceId, + }, + mail: updateMailInSequence( + sequenceId: $sequenceId, + emailId: $emailId, + subject: $title, + content: $content, + delayInMillis: $delayInMillis, + ) { + sequenceId, + title, + emails { + emailId, + templateId, + content { + content { + blockType, + settings + }, + style, + meta + }, + subject, + delayInMillis, + published + }, + filter { + aggregator, + filters { + name, + condition, + value, + valueLabel + }, + } + }, + }`; + + const fetcher = fetch + .setPayload({ + query: mutation, + variables: { + sequenceId: id, + emailId, + title: subject, + filter: JSON.stringify({ + aggregator: filtersAggregator, + filters, + }), + content: JSON.stringify(content), + delayInMillis: delay, + }, + }) + .build(); + + try { + await fetcher.exec(); + + // Update initial values after successful save + initialValues.current = { + subject, + content, + delay, + filters: [...filters], + filtersAggregator, + }; + } catch (e: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: e.message, + variant: "destructive", + }); + } + }, 1000); // 1 second debounce + }, [ + emailId, + subject, + content, + delay, + filters, + filtersAggregator, + id, + fetch, + toast, + ]); + + // Trigger debounced save when values change + useEffect(() => { + debouncedSave(); + }, [debouncedSave]); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + }; + }, []); + + const onSubmit = async (e: FormEvent, sendLater: boolean = false) => { + e.preventDefault(); + + if (!subject.trim()) { + toast({ + title: TOAST_TITLE_ERROR, + description: ERROR_SUBJECT_EMPTY, + variant: "destructive", + }); + setConfirmationDialogOpen(false); + return; + } + + if (sendLater && delay === 0) { + toast({ + title: TOAST_TITLE_ERROR, + description: ERROR_DELAY_EMPTY, + variant: "destructive", + }); + setConfirmationDialogOpen(false); + return; + } + + const mutation = ` + mutation ( + $sequenceId: String! + $emailId: String! + $delayInMillis: Float + ) { + updateMailInSequence( + sequenceId: $sequenceId + emailId: $emailId + delayInMillis: $delayInMillis + published: true + ) { + sequenceId, + } + sequence: startSequence(sequenceId: $sequenceId) { + sequenceId, + title, + emails { + emailId, + templateId, + content { + content { + blockType, + settings + }, + style, + meta + }, + subject, + delayInMillis, + published + }, + filter { + aggregator, + filters { + name, + condition, + value, + valueLabel + }, + }, + report { + broadcast { + lockedAt, + sentAt + } + }, + status + } + }`; + + const fetcher = fetch + .setPayload({ + query: mutation, + variables: { + sequenceId: id, + emailId, + delayInMillis: sendLater ? delay : 0, + }, + }) + .build(); + + try { + const response = await fetcher.exec(); + if (response.sequence) { + const { sequence } = response; + setSubject(sequence.emails[0].subject); + setContent(sequence.emails[0].content); + setDelay(sequence.emails[0].delayInMillis); + setEmailId(sequence.emails[0].emailId); + // setPublished(sequence.emails[0].published); + if (sequence.filter) { + setFilters(sequence.filter.filters); + setFiltersAggregator(sequence.filter.aggregator); + } + setReport(sequence.report); + setStatus(sequence.status); + setShowScheduleInput(false); + toast({ + title: TOAST_TITLE_SUCCESS, + description: TOAST_MAIL_SENT, + }); + } + } catch (e: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: e.message, + variant: "destructive", + }); + } finally { + setConfirmationDialogOpen(false); + } + }; + + const cancelSending = async () => { + const mutation = ` + mutation PauseSequence( + $sequenceId: String! + ) { + sequence: pauseSequence( + sequenceId: $sequenceId + ) { + sequenceId, + title, + emails { + emailId, + templateId, + content, + subject, + delayInMillis, + published + }, + filter { + aggregator, + filters { + name, + condition, + value, + valueLabel + }, + }, + report { + broadcast { + lockedAt, + sentAt + } + }, + status + } + }`; + + const fetcher = fetch + .setPayload({ + query: mutation, + variables: { + sequenceId: id, + }, + }) + .build(); + + try { + const response = await fetcher.exec(); + if (response.sequence) { + const { sequence } = response; + setSubject(sequence.emails[0].subject); + setContent(sequence.emails[0].content); + setDelay(sequence.emails[0].delayInMillis); + setEmailId(sequence.emails[0].emailId); + // setPublished(sequence.emails[0].published); + if (sequence.filter) { + setFilters(sequence.filter.filters); + setFiltersAggregator(sequence.filter.aggregator); + } + setReport(sequence.report); + setStatus(sequence.status); + } + } catch (e: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: e.message, + variant: "destructive", + }); + } + }; + + const onFilterChange = useCallback( + ({ + filters: newFilters, + aggregator, + segmentId, + count: filteredCount, + }) => { + if ( + JSON.stringify(filters) !== JSON.stringify(newFilters) || + filtersAggregator !== aggregator || + filteredUsersCount !== filteredCount + ) { + setFilters(newFilters); + setFiltersAggregator(aggregator); + setFilteredUsersCount(filteredCount); + } + }, + [filters, filtersAggregator, filteredUsersCount], + ); + + const isEditable = useMemo(() => { + return Boolean( + status && + [ + "draft" as SequenceStatus, + "paused" as SequenceStatus, + ].includes(status), + ); + }, [status]); + + if (loading || !sequence) { + return null; + } return ( - +
+
+

+ {PAGE_HEADER_EDIT_MAIL} +

+
+
+ + {!isInitialLoad.current && ( + + )} +
+
+ ) => + setSubject(e.target.value) + } + /> + + {showScheduleInput && ( + ) => { + const selectedDate = new Date(e.target.value); + setDelay(selectedDate.getTime()); + }} + /> + )} + {isEditable && ( +
+ {!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? +

+
+
+ + + )} +
+ )} + + {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/mail-hub.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/mail-hub.tsx index 7d876e8c7..2fcccde76 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 @@ -20,7 +20,7 @@ export default function MailHub() { const breadcrumbs = [{ label: tab, href: "#" }]; - if (!checkPermission(profile.permissions!, [permissions.manageSite])) { + if (!checkPermission(profile?.permissions!, [permissions.manageSite])) { return ; } 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 7bf72240d..3f289ccf6 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 @@ -1,14 +1,27 @@ "use client"; import DashboardContent from "@components/admin/dashboard-content"; -import SequenceMailEditor from "@components/admin/mails/sequence-mail-editor"; -import { AddressContext } from "@components/contexts"; +import { PAGE_HEADER_EDIT_SEQUENCE, SEQUENCES } from "@ui-config/strings"; import { + Button, + Form, + FormField, + Select, + useToast, +} from "@courselit/components-library"; +import { + BUTTON_SAVE, + COMPOSE_SEQUENCE_EDIT_DELAY, + TOAST_TITLE_ERROR, + MAIL_SUBJECT_PLACEHOLDER, PAGE_HEADER_EDIT_MAIL, - PAGE_HEADER_EDIT_SEQUENCE, - SEQUENCES, } from "@ui-config/strings"; -import { useContext } from "react"; +import { ChangeEvent, useCallback, useEffect, useState } from "react"; +import { Email as EmailContent } from "@courselit/email-editor"; +import { useGraphQLFetch } from "@/hooks/use-graphql-fetch"; +import { Email } from "@courselit/common-models"; +import EmailViewer from "@components/admin/mails/email-viewer"; +import { useSequence } from "@/hooks/use-sequence"; export default function Page({ params, @@ -18,27 +31,324 @@ export default function Page({ mailId: string; }; }) { - const address = useContext(AddressContext); - const { id, mailId } = params; + const { id: sequenceId, mailId } = params; const breadcrumbs = [ { label: SEQUENCES, href: "/dashboard/mails?tab=Sequences" }, { label: PAGE_HEADER_EDIT_SEQUENCE, - href: `/dashboard/mails/sequence/${id}`, + href: `/dashboard/mails/sequence/${sequenceId}`, }, { label: PAGE_HEADER_EDIT_MAIL, href: "#", }, ]; + 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">( + "unpublished", + ); + const { toast } = useToast(); + 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]); + + useEffect(() => { + if (sequence) { + const email = sequence.emails.find((e) => e.emailId === mailId); + if (email) { + setEmail(email); + setDelay(email.delayInMillis / 86400000); + setSubject(email.subject); + // setPreviewText(email.content?.meta?.previewText || ""); + setContent(email.content || null); + setPublished(email.published ? "published" : "unpublished"); + } + } + }, [sequence, mailId]); + + const updateMail = useCallback(async () => { + const query = ` + mutation UpdateMail( + $sequenceId: String! + $emailId: String! + $subject: String + $content: String + $delayInMillis: Float + $templateId: String + $actionType: SequenceEmailActionType + $actionData: String + $published: Boolean + ) { + sequence: updateMailInSequence( + sequenceId: $sequenceId, + emailId: $emailId, + subject: $subject, + content: $content, + delayInMillis: $delayInMillis, + templateId: $templateId, + actionType: $actionType, + actionData: $actionData, + published: $published, + ) { + 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, + emailId: email?.emailId, + subject, + content: JSON.stringify(content), + delayInMillis: delay * 86400000, + published: published === "published", + // previewText, + }, + }) + .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, + email, + subject, + content, + delay, + published, + // previewText, + ]); + + useEffect(() => { + if (error) { + toast({ + title: TOAST_TITLE_ERROR, + description: error, + variant: "destructive", + }); + } + }, [error, toast]); + + if (loading || !sequence) { + return null; + } return ( - +
+
+

+ {PAGE_HEADER_EDIT_MAIL} +

+
+ +
+
+
+
+
+ ) => + setDelay(+e.target.value) + } + endIcon={days} + className="w-1/2" + tooltip="The delay in days after which the email will be sent after the last mail." + /> +
+ setPublished(value)} - title="Status" - options={[ - { - label: "Published", - value: "published", - }, - { - label: "Unpublished", - value: "unpublished", - }, - ]} - /> -
-
- ) => - setSubject(e.target.value) - } - /> - ) => - setPreviewText(e.target.value) - } - tooltip="This text will be shown in the email client before opening the email." - /> - - -
-
- ); -}; - -export default SequenceMailEditor; diff --git a/apps/web/components/admin/mails/sequences-list.tsx b/apps/web/components/admin/mails/sequences-list.tsx index aaf568f19..971e89dd9 100644 --- a/apps/web/components/admin/mails/sequences-list.tsx +++ b/apps/web/components/admin/mails/sequences-list.tsx @@ -6,15 +6,7 @@ import { Sequence, SequenceType, } from "@courselit/common-models"; -import { - Chip, - Link, - Table, - TableBody, - TableHead, - TableRow, - useToast, -} from "@courselit/components-library"; +import { Chip, Link, useToast } from "@courselit/components-library"; import { AppDispatch } from "@courselit/state-management"; import { networkAction } from "@courselit/state-management/dist/action-creators"; import { FetchBuilder, capitalize } from "@courselit/utils"; @@ -26,6 +18,16 @@ import { } from "@ui-config/strings"; import { useEffect, useState } from "react"; import { isDateInFuture } from "../../../lib/utils"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { PaginationControls } from "@components/public/pagination"; +import { Skeleton } from "@/components/ui/skeleton"; interface SequencesListProps { address: Address; @@ -41,7 +43,6 @@ const SequencesList = ({ type, }: SequencesListProps) => { const [page, setPage] = useState(1); - // const [rowsPerPage, setRowsPerPage] = useState(10); const [count, setCount] = useState(0); const [sequences, setSequences] = useState< Pick< @@ -49,6 +50,7 @@ const SequencesList = ({ "sequenceId" | "title" | "emails" | "status" | "entrantsCount" >[] >([]); + const [isLoading, setIsLoading] = useState(false); const { toast } = useToast(); const handlePageChange = (newPage: number) => { @@ -68,6 +70,7 @@ const SequencesList = ({ }, []); const loadSequences = async () => { + setIsLoading(true); const query = ` query GetSequences($page: Int, $type: SequenceType!) { broadcasts: getSequences( @@ -110,6 +113,7 @@ const SequencesList = ({ }); } finally { dispatch && dispatch(networkAction(false)); + setIsLoading(false); } }; @@ -145,97 +149,137 @@ const SequencesList = ({ } }; + const totalPages = Math.ceil(count / 10); // 10 items per page + return ( - - - - - {type === "sequence" && ( - - )} - - - {sequences.map((broadcast) => ( - - - +
+
{MAIL_TABLE_HEADER_SUBJECT}{MAIL_TABLE_HEADER_STATUS}{MAIL_TABLE_HEADER_ENTRANTS} - - {type === "broadcast" && - (broadcast.emails[0].subject === " " - ? "--" - : broadcast.emails[0].subject)} - {type === "sequence" && - (broadcast.title === " " - ? "Untitled Sequence" - : broadcast.title)} - - - {type === "broadcast" && ( - <> - {broadcast.status === - Constants.sequenceStatus[1] && - !isDateInFuture( - new Date( - broadcast.emails[0].delayInMillis, - ), - ) && ( - - Sent - - )} - {broadcast.status === - Constants.sequenceStatus[1] && - isDateInFuture( - new Date( - broadcast.emails[0].delayInMillis, - ), - ) && Scheduled} - {[ - Constants.sequenceStatus[0], - Constants.sequenceStatus[2], - ].includes( - broadcast.status as - | (typeof Constants.sequenceStatus)[0] - | (typeof Constants.sequenceStatus)[2], - ) && Draft} - - )} - {type === "sequence" && ( - <> - {[ - Constants.sequenceStatus[0], - Constants.sequenceStatus[2], - ].includes( - broadcast.status as "draft" | "paused", - ) && ( - - {capitalize(broadcast.status)} - - )} - {broadcast.status === - Constants.sequenceStatus[1] && ( - - {capitalize(broadcast.status)} - - )} - - )} -
+ + + {MAIL_TABLE_HEADER_SUBJECT} + + {MAIL_TABLE_HEADER_STATUS} + {type === "sequence" && ( - + + {MAIL_TABLE_HEADER_ENTRANTS} + )} - ))} - -
{broadcast.entrantsCount}
+ + + {isLoading + ? Array.from({ length: 10 }).map((_, idx) => ( + + + + + + + + {type === "sequence" && ( + + + + )} + + )) + : sequences.map((broadcast) => ( + + + + {type === "broadcast" && + (broadcast.emails[0].subject === + " " + ? "--" + : broadcast.emails[0] + .subject)} + {type === "sequence" && + (broadcast.title === " " + ? "Untitled Sequence" + : broadcast.title)} + + + + {type === "broadcast" && ( + <> + {broadcast.status === + Constants.sequenceStatus[1] && + !isDateInFuture( + new Date( + broadcast.emails[0].delayInMillis, + ), + ) && ( + + Sent + + )} + {broadcast.status === + Constants.sequenceStatus[1] && + isDateInFuture( + new Date( + broadcast.emails[0].delayInMillis, + ), + ) && Scheduled} + {[ + Constants.sequenceStatus[0], + Constants.sequenceStatus[2], + ].includes( + broadcast.status as + | (typeof Constants.sequenceStatus)[0] + | (typeof Constants.sequenceStatus)[2], + ) && Draft} + + )} + {type === "sequence" && ( + <> + {[ + Constants.sequenceStatus[0], + Constants.sequenceStatus[2], + ].includes( + broadcast.status as + | "draft" + | "paused", + ) && ( + + {capitalize( + broadcast.status, + )} + + )} + {broadcast.status === + Constants + .sequenceStatus[1] && ( + + {capitalize( + broadcast.status, + )} + + )} + + )} + + {type === "sequence" && ( + + {broadcast.entrantsCount} + + )} + + ))} + + + {totalPages > 1 && ( + + )} + ); }; diff --git a/apps/web/components/admin/products/editor/details.tsx b/apps/web/components/admin/products/editor/details.tsx deleted file mode 100644 index 1f117fee2..000000000 --- a/apps/web/components/admin/products/editor/details.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import React, { FormEvent, useEffect, useState } from "react"; -import { - MediaSelector, - TextEditor, - TextEditorEmptyDoc, - Button, - Form, - FormField, - PageBuilderPropertyHeader, - useToast, -} from "@courselit/components-library"; -import useCourse from "./course-hook"; -import { FetchBuilder } from "@courselit/utils"; -import { networkAction } from "@courselit/state-management/dist/action-creators"; -import { Address, Media, Profile } from "@courselit/common-models"; -import { - APP_MESSAGE_COURSE_SAVED, - BUTTON_SAVE, - COURSE_CONTENT_HEADER, - TOAST_TITLE_ERROR, - FORM_FIELD_FEATURED_IMAGE, - TOAST_TITLE_SUCCESS, -} from "../../../../ui-config/strings"; -import { AppDispatch } from "@courselit/state-management"; -import { MIMETYPE_IMAGE } from "../../../../ui-config/constants"; - -interface DetailsProps { - id: string; - profile: Profile; - address: Address; - dispatch?: AppDispatch; -} - -export default function Details({ - id, - address, - dispatch, - profile, -}: DetailsProps) { - const [title, setTitle] = useState(""); - const [description, setDescription] = useState(TextEditorEmptyDoc); - const [featuredImage, setFeaturedImage] = useState>({}); - const [refresh, setRefresh] = useState(0); - const course = useCourse(id, address, dispatch); - const { toast } = useToast(); - - useEffect(() => { - if (course) { - setTitle(course.title || ""); - setDescription( - course.description - ? JSON.parse(course.description) - : TextEditorEmptyDoc, - ); - setFeaturedImage(course.featuredImage || {}); - setRefresh(refresh + 1); - } - }, [course]); - - const updateDetails = async (e: FormEvent) => { - e.preventDefault(); - - const mutation = ` - mutation { - updateCourse(courseData: { - id: "${course!.courseId}", - title: "${title}", - description: ${JSON.stringify(JSON.stringify(description))} - }) { - courseId - } - } - `; - const fetch = new FetchBuilder() - .setUrl(`${address.backend}/api/graph`) - .setPayload(mutation) - .setIsGraphQLEndpoint(true) - .build(); - try { - dispatch && dispatch(networkAction(true)); - const response = await fetch.exec(); - if (response.updateCourse) { - toast({ - title: TOAST_TITLE_SUCCESS, - description: APP_MESSAGE_COURSE_SAVED, - }); - } - } catch (err: any) { - toast({ - title: TOAST_TITLE_ERROR, - description: err.message, - variant: "destructive", - }); - } finally { - dispatch && dispatch(networkAction(false)); - } - }; - - const saveFeaturedImage = async (media?: Media) => { - const mutation = ` - mutation ($courseId: String!, $media: MediaInput) { - updateCourse(courseData: { - id: $courseId - featuredImage: $media - }) { - courseId - } - } - `; - const fetch = new FetchBuilder() - .setUrl(`${address.backend}/api/graph`) - .setPayload({ - query: mutation, - variables: { - courseId: course?.courseId, - media: media || null, - }, - }) - .setIsGraphQLEndpoint(true) - .build(); - try { - dispatch && dispatch(networkAction(true)); - const response = await fetch.exec(); - if (response.updateCourse) { - toast({ - title: TOAST_TITLE_SUCCESS, - description: APP_MESSAGE_COURSE_SAVED, - }); - } - } catch (err: any) { - toast({ - title: TOAST_TITLE_ERROR, - description: err.message, - variant: "destructive", - }); - } finally { - dispatch && dispatch(networkAction(false)); - } - }; - - return ( -
-
- setTitle(e.target.value)} - /> - - { - setDescription(state); - }} - refresh={refresh} - url={address.backend} - /> -
- -
- - { - media && setFeaturedImage(media); - saveFeaturedImage(media); - }} - mimeTypesToShow={[...MIMETYPE_IMAGE]} - access="public" - strings={{}} - profile={profile} - address={address} - mediaId={(featuredImage && featuredImage.mediaId) || ""} - onRemove={() => { - setFeaturedImage({}); - saveFeaturedImage(); - }} - type="course" - /> -
- ); -} diff --git a/apps/web/components/admin/products/editor/pricing.tsx b/apps/web/components/admin/products/editor/pricing.tsx deleted file mode 100644 index 675345806..000000000 --- a/apps/web/components/admin/products/editor/pricing.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import React, { FormEvent, useEffect, useState } from "react"; -import { Address, SiteInfo } from "@courselit/common-models"; -import { - FormField, - Form, - Button, - Select, - useToast, -} from "@courselit/components-library"; -import type { AppDispatch } from "@courselit/state-management"; -import { networkAction } from "@courselit/state-management/dist/action-creators"; -import { FetchBuilder } from "@courselit/utils"; -import { - APP_MESSAGE_COURSE_SAVED, - BUTTON_SAVE, - PRICING_DROPDOWN, - PRICING_EMAIL, - PRICING_EMAIL_LABEL, - PRICING_EMAIL_SUBTITLE, - PRICING_FREE, - PRICING_FREE_LABEL, - PRICING_FREE_SUBTITLE, - PRICING_PAID, - PRICING_PAID_LABEL, - PRICING_PAID_NO_PAYMENT_METHOD, - PRICING_PAID_SUBTITLE, - TOAST_TITLE_ERROR, - TOAST_TITLE_SUCCESS, -} from "../../../../ui-config/strings"; -import useCourse from "./course-hook"; -import { COURSE_TYPE_DOWNLOAD } from "../../../../ui-config/constants"; - -interface PricingProps { - id: string; - siteinfo: SiteInfo; - address: Address; - dispatch?: AppDispatch; -} - -export default function Pricing({ - id, - siteinfo, - address, - dispatch, -}: PricingProps) { - const course = useCourse(id, address); - const [cost, setCost] = useState(course?.cost); - const [costType, setCostType] = useState( - course?.costType?.toLowerCase() || PRICING_FREE, - ); - const { toast } = useToast(); - - useEffect(() => { - if (course) { - setCost(course!.cost); - setCostType(course!.costType!.toLowerCase()); - } - }, [course]); - - const updatePricing = async (e: FormEvent) => { - e.preventDefault(); - - const mutation = ` - mutation { - updateCourse(courseData: { - id: "${course!.courseId}", - costType: ${costType.toUpperCase()}, - cost: ${cost} - }) { - courseId - } - } - `; - const fetch = new FetchBuilder() - .setUrl(`${address.backend}/api/graph`) - .setPayload(mutation) - .setIsGraphQLEndpoint(true) - .build(); - try { - dispatch && dispatch(networkAction(true)); - const response = await fetch.exec(); - if (response.updateCourse) { - toast({ - title: TOAST_TITLE_SUCCESS, - description: APP_MESSAGE_COURSE_SAVED, - }); - } - } catch (err: any) { - toast({ - title: TOAST_TITLE_ERROR, - description: err.message, - variant: "destructive", - }); - } finally { - dispatch && dispatch(networkAction(false)); - } - }; - - if (!course?.courseId) { - return null; - } - - const options = [ - { - label: PRICING_FREE_LABEL, - value: PRICING_FREE, - sublabel: PRICING_FREE_SUBTITLE, - }, - { - label: PRICING_PAID_LABEL, - value: PRICING_PAID, - sublabel: siteinfo.paymentMethod - ? PRICING_PAID_SUBTITLE - : PRICING_PAID_NO_PAYMENT_METHOD, - disabled: !siteinfo.paymentMethod, - }, - ]; - if (course.type?.toLowerCase() === COURSE_TYPE_DOWNLOAD) { - options.splice(1, 0, { - label: PRICING_EMAIL_LABEL, - value: PRICING_EMAIL, - sublabel: PRICING_EMAIL_SUBTITLE, - }); - } - - return ( -
- - {type === Constants.dripType[1] && ( - , - ) => { - const selectedDate = new Date( - e.target.value, - ); - setDate(selectedDate.getTime()); - }} - /> - )} - {type === Constants.dripType[0] && ( - setDelay(+e.target.value)} - required - /> - )} - {type && ( - <> -

- Notify users -

-
-

- Send email notification to the users - when this section has dripped -

- - setNotifyUsers(value) - } - /> -
- {notifyUsers && ( -
- - setEmailSubject( - e.target.value, - ) - } - required - /> - -
- )} - - )} - - - )} -
- - {course.courseId && ( - - - - )} -
- - - ); -} diff --git a/apps/web/components/admin/users/filter-container/filter-editor/categories-map.ts b/apps/web/components/admin/users/filter-container/filter-editor/categories-map.ts deleted file mode 100644 index 4947c6af4..000000000 --- a/apps/web/components/admin/users/filter-container/filter-editor/categories-map.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { UserFilter } from "@courselit/common-models"; -import { - USER_FILTER_CATEGORY_EMAIL, - USER_FILTER_CATEGORY_LAST_ACTIVE, - USER_FILTER_CATEGORY_PERMISSION, - USER_FILTER_CATEGORY_PRODUCT, - USER_FILTER_CATEGORY_SIGNED_UP, - USER_FILTER_CATEGORY_SUBSCRIPTION, - USER_FILTER_CATEGORY_TAGGED, - USER_FILTER_CATEGORY_COMMUNITY, -} from "@ui-config/strings"; - -const categoriesMap: Record = { - email: USER_FILTER_CATEGORY_EMAIL, - product: USER_FILTER_CATEGORY_PRODUCT, - community: USER_FILTER_CATEGORY_COMMUNITY, - lastActive: USER_FILTER_CATEGORY_LAST_ACTIVE, - signedUp: USER_FILTER_CATEGORY_SIGNED_UP, - subscription: USER_FILTER_CATEGORY_SUBSCRIPTION, - tag: USER_FILTER_CATEGORY_TAGGED, - permission: USER_FILTER_CATEGORY_PERMISSION, -}; - -export default categoriesMap; diff --git a/apps/web/components/admin/users/filter-container/filter-editor/community.tsx b/apps/web/components/admin/users/filter-container/filter-editor/community.tsx deleted file mode 100644 index ea6012225..000000000 --- a/apps/web/components/admin/users/filter-container/filter-editor/community.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import React, { useState, useMemo } from "react"; -import { - POPUP_CANCEL_ACTION, - USER_FILTER_APPLY_BTN, - USER_FILTER_CATEGORY_COMMUNITY, - USER_FILTER_COMMUNITY_HAS, - USER_FILTER_COMMUNITY_DOES_NOT_HAVE, - USER_FILTER_COMMUNITY_DROPDOWN_LABEL, -} from "@ui-config/strings"; -import { AppDispatch } from "@courselit/state-management"; -import PopoverHeader from "../popover-header"; -import { - Button, - Form, - FormSubmit, - Select, - useToast, -} from "@courselit/components-library"; -import { Address } from "@courselit/common-models"; -import { useCommunities } from "@/hooks/use-communities"; - -interface ProductFilterEditorProps { - onApply: (...args: any[]) => any; - address: Address; - dispatch?: AppDispatch; -} - -export default function CommunityFilterEditor({ - onApply, - address, - dispatch, -}: ProductFilterEditorProps) { - const [condition, setCondition] = useState(USER_FILTER_COMMUNITY_HAS); - const [value, setValue] = useState(""); - const { toast } = useToast(); - const { communities } = useCommunities(1, 1_000_000); - - const onSubmit = (e: any) => { - e.preventDefault(); - const buttonName = e.nativeEvent.submitter.name; - if (buttonName === "apply") { - onApply({ - condition, - value, - valueLabel: communities.find((x) => x.communityId === value) - ?.name, - }); - } else { - onApply(); - } - }; - - const communityOptions = useMemo(() => { - const options: { label: string; value: string; disabled?: boolean }[] = - communities.map((community) => ({ - label: community.name, - value: community.communityId, - })); - return options; - }, [communities]); - - return ( -
- {USER_FILTER_CATEGORY_COMMUNITY} - -
- - -
-
- ); -} diff --git a/apps/web/components/admin/users/filter-container/filter-editor/email.tsx b/apps/web/components/admin/users/filter-container/filter-editor/email.tsx deleted file mode 100644 index 3b88413cc..000000000 --- a/apps/web/components/admin/users/filter-container/filter-editor/email.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import React, { useState } from "react"; -import { - POPUP_CANCEL_ACTION, - USER_FILTER_APPLY_BTN, - USER_FILTER_CATEGORY_EMAIL, - USER_FILTER_EMAIL_CONTAINS, - USER_FILTER_EMAIL_IS_EXACTLY, - USER_FILTER_EMAIL_NOT_CONTAINS, -} from "@ui-config/strings"; -import { - Button, - Form, - FormField, - FormSubmit, - Select, -} from "@courselit/components-library"; -import PopoverHeader from "../popover-header"; -import { FormEvent } from "react"; - -interface EmailFilterEditorProps { - onApply: (...args: any[]) => any; -} - -export default function EmailFilterEditor({ onApply }: EmailFilterEditorProps) { - const [condition, setCondition] = useState(USER_FILTER_EMAIL_IS_EXACTLY); - const [value, setValue] = useState(""); - - const onSubmit = (e: any) => { - e.preventDefault(); - const buttonName = e.nativeEvent.submitter.name; - if (buttonName === "apply") { - onApply({ condition, value }); - } else { - onApply(); - } - }; - - return ( -
- {USER_FILTER_CATEGORY_EMAIL} - - ) => - setValue(e.target.value) - } - /> -
- - -
- - ); -} diff --git a/apps/web/components/admin/users/filter-container/filter-editor/permission.tsx b/apps/web/components/admin/users/filter-container/filter-editor/permission.tsx deleted file mode 100644 index 376fa131a..000000000 --- a/apps/web/components/admin/users/filter-container/filter-editor/permission.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React, { useState } from "react"; -import { - Button, - Form, - FormSubmit, - Select, -} from "@courselit/components-library"; -import { - POPUP_CANCEL_ACTION, - USER_FILTER_APPLY_BTN, - USER_FILTER_CATEGORY_PERMISSION, - USER_FILTER_PERMISSION_DOES_NOT_HAVE, - USER_FILTER_PERMISSION_DROPDOWN_LABEL, - USER_FILTER_PERMISSION_HAS, -} from "@ui-config/strings"; -import permissionToCaptionMap from "../../permissions-to-caption-map"; -import PopoverHeader from "../popover-header"; - -interface PermissionFilterEditorProps { - onApply: (...args: any[]) => any; -} - -export default function PermissionFilterEditor({ - onApply, -}: PermissionFilterEditorProps) { - const [condition, setCondition] = useState(USER_FILTER_PERMISSION_HAS); - const [value, setValue] = useState(""); - - const onSubmit = (e: any) => { - e.preventDefault(); - const buttonName = e.nativeEvent.submitter.name; - if (buttonName === "apply") { - onApply({ - condition, - value, - }); - } else { - onApply(); - } - }; - - const options: { label: string; value: string; disabled?: boolean }[] = - Object.keys(permissionToCaptionMap).map((permission) => ({ - label: permissionToCaptionMap[permission], - value: permission, - })); - - return ( -
- {USER_FILTER_CATEGORY_PERMISSION} - -
- - -
-
- ); -} diff --git a/apps/web/components/admin/users/filter-container/filter-editor/product.tsx b/apps/web/components/admin/users/filter-container/filter-editor/product.tsx deleted file mode 100644 index e2520720b..000000000 --- a/apps/web/components/admin/users/filter-container/filter-editor/product.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import React, { useState, useEffect, useCallback, useMemo } from "react"; -import { FetchBuilder } from "@courselit/utils"; -import { - TOAST_TITLE_ERROR, - POPUP_CANCEL_ACTION, - USER_FILTER_APPLY_BTN, - USER_FILTER_CATEGORY_PRODUCT, - USER_FILTER_PRODUCT_DOES_NOT_HAVE, - USER_FILTER_PRODUCT_DROPDOWN_LABEL, - USER_FILTER_PRODUCT_HAS, -} from "@ui-config/strings"; -import { AppDispatch } from "@courselit/state-management"; -import PopoverHeader from "../popover-header"; -import { - Button, - Form, - FormSubmit, - Select, - useToast, -} from "@courselit/components-library"; -import { Address, Course } from "@courselit/common-models"; - -interface ProductFilterEditorProps { - onApply: (...args: any[]) => any; - address: Address; - dispatch?: AppDispatch; -} - -export default function ProductFilterEditor({ - onApply, - address, - dispatch, -}: ProductFilterEditorProps) { - const [condition, setCondition] = useState(USER_FILTER_PRODUCT_HAS); - const [value, setValue] = useState(""); - const [products, setProducts] = useState< - Pick[] - >([]); - const { toast } = useToast(); - - const loadCreatorCourses = useCallback(async () => { - const query = ` - query { courses: getCoursesAsAdmin( - offset: 1 - ) { - title, - courseId, - } - } - `; - const fetch = new FetchBuilder() - .setUrl(`${address.backend}/api/graph`) - .setPayload(query) - .setIsGraphQLEndpoint(true) - .build(); - try { - const response = await fetch.exec(); - if (response.courses) { - setProducts([...response.courses]); - } - } catch (err: any) { - toast({ - title: TOAST_TITLE_ERROR, - description: err.message, - variant: "destructive", - }); - } - }, [address.backend, dispatch]); - - useEffect(() => { - loadCreatorCourses(); - }, [loadCreatorCourses]); - - const onSubmit = (e: any) => { - e.preventDefault(); - const buttonName = e.nativeEvent.submitter.name; - if (buttonName === "apply") { - onApply({ - condition, - value, - valueLabel: products.find((x) => x.courseId === value).title, - }); - } else { - onApply(); - } - }; - - const productOptions = useMemo(() => { - const options: { label: string; value: string; disabled?: boolean }[] = - products.map((product) => ({ - label: product.title, - value: product.courseId, - })); - return options; - }, [products]); - - return ( -
- {USER_FILTER_CATEGORY_PRODUCT} - -
- - -
-
- ); -} diff --git a/apps/web/components/admin/users/filter-container/filter-editor/signed-up.tsx b/apps/web/components/admin/users/filter-container/filter-editor/signed-up.tsx deleted file mode 100644 index dc10011bc..000000000 --- a/apps/web/components/admin/users/filter-container/filter-editor/signed-up.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import React, { FormEvent, useState } from "react"; -import { - Button, - Form, - FormField, - FormSubmit, - Select, -} from "@courselit/components-library"; -import { - POPUP_CANCEL_ACTION, - USER_FILTER_APPLY_BTN, - USER_FILTER_CATEGORY_SIGNED_UP, - USER_FILTER_DATE_RANGE_DROPDOWN_LABEL, - USER_FILTER_LAST_ACTIVE_AFTER, - USER_FILTER_SIGNED_UP_AFTER, - USER_FILTER_SIGNED_UP_BEFORE, - USER_FILTER_SIGNED_UP_ON, -} from "@ui-config/strings"; -import PopoverHeader from "../popover-header"; - -interface SignedUpFilterEditorProps { - onApply: (...args: any[]) => any; -} - -export default function SignedUpFilterEditor({ - onApply, -}: SignedUpFilterEditorProps) { - const [condition, setCondition] = useState(USER_FILTER_LAST_ACTIVE_AFTER); - const [value, setValue] = useState(""); - - const onSubmit = (e: any) => { - e.preventDefault(); - const buttonName = e.nativeEvent.submitter.name; - if (buttonName === "apply") { - onApply({ - condition, - value, - }); - } else { - onApply(); - } - }; - - return ( -
- {USER_FILTER_CATEGORY_SIGNED_UP} - -
- - -
-
- ); -} diff --git a/apps/web/components/admin/users/filter-container/filter-editor/tagged.tsx b/apps/web/components/admin/users/filter-container/filter-editor/tagged.tsx deleted file mode 100644 index de92d956e..000000000 --- a/apps/web/components/admin/users/filter-container/filter-editor/tagged.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { Address } from "@courselit/common-models"; -import { - Button, - Form, - FormSubmit, - Select, - useToast, -} from "@courselit/components-library"; -import { AppDispatch } from "@courselit/state-management"; -import { FetchBuilder } from "@courselit/utils"; -import { - TOAST_TITLE_ERROR, - POPUP_CANCEL_ACTION, - USER_FILTER_APPLY_BTN, - USER_FILTER_CATEGORY_TAGGED, - USER_FILTER_PRODUCT_DOES_NOT_HAVE, - USER_FILTER_PRODUCT_HAS, - USER_FILTER_TAGGED_DROPDOWN_LABEL, -} from "@ui-config/strings"; -import React, { useState } from "react"; -import { useCallback } from "react"; -import { useMemo } from "react"; -import PopoverHeader from "../popover-header"; -import { useEffect } from "react"; - -interface TaggedFilterEditorProps { - onApply: (...args: any[]) => any; - address: Address; - dispatch?: AppDispatch; -} - -export default function TaggedFilterEditor({ - onApply, - address, - dispatch, -}: TaggedFilterEditorProps) { - const [condition, setCondition] = useState(USER_FILTER_PRODUCT_HAS); - const [value, setValue] = useState(""); - const [tags, setTags] = useState([]); - const { toast } = useToast(); - - const getTags = useCallback(async () => { - const query = ` - query { - tags - } - `; - const fetch = new FetchBuilder() - .setUrl(`${address.backend}/api/graph`) - .setPayload(query) - .setIsGraphQLEndpoint(true) - .build(); - try { - const response = await fetch.exec(); - if (response.tags) { - setTags(response.tags); - } - } catch (err) { - toast({ - title: TOAST_TITLE_ERROR, - description: err.message, - variant: "destructive", - }); - } - }, [address.backend, dispatch]); - - useEffect(() => { - getTags(); - }, [getTags]); - - const onSubmit = (e: any) => { - e.preventDefault(); - const buttonName = e.nativeEvent.submitter.name; - if (buttonName === "apply") { - onApply({ condition, value }); - } else { - onApply(); - } - }; - - const tagOptions = useMemo(() => { - const options: { label: string; value: string; disabled?: boolean }[] = - tags.map((tag) => ({ - label: tag, - value: tag, - })); - return options; - }, [tags]); - - return ( -
- {USER_FILTER_CATEGORY_TAGGED} - -
- - -
-
- ); -} diff --git a/apps/web/components/admin/users/filter-container/index.tsx b/apps/web/components/admin/users/filter-container/index.tsx index 023badd83..0ccf03137 100644 --- a/apps/web/components/admin/users/filter-container/index.tsx +++ b/apps/web/components/admin/users/filter-container/index.tsx @@ -42,8 +42,7 @@ import { Filter } from "lucide-react"; import { Input } from "@components/ui/input"; const FilterChip = dynamic(() => import("./filter-chip")); const FilterSave = dynamic(() => import("./filter-save")); -const FilterEditor = dynamic(() => import("./filter-editor")); -const FilterEditor2 = dynamic(() => import("./filter-editor-2")); +const FilterEditor = dynamic(() => import("./filter-editor-2")); const { networkAction } = actionCreators; interface FilterContainerProps { @@ -217,7 +216,7 @@ export default function FilterContainer({ }, [loadCount]); const searchByEmail = useCallback(async () => { - const newFilters = [ + const newFilters: UserFilter[] = [ ...internalFilters, { name: "email", @@ -238,58 +237,6 @@ export default function FilterContainer({ return (
- {/* - - { - segments.filter( - (segment) => - segment.segmentId === activeSegment, - )[0]?.name - } - - } - disabled={disabled} - > - { - if (!cancelled) { - if (receivedSegments) { - mapSegments(receivedSegments); - } - const selectedSeg = segments.find( - (segment) => - segment.segmentId === selectedSegment, - ); - setInternalFilters([ - ...selectedSeg.filter.filters, - ]); - setInternalAggregator( - selectedSeg.filter.aggregator, - ); - setActiveSegment(selectedSegment); - onChange({ - filters: [...selectedSeg.filter.filters], - aggregator: selectedSeg.filter.aggregator, - segmentId: selectedSegment, - count, - }); - } - setSegmentSelectOpen(false); - }} - /> - */} + + + {okButton || ( + - - - {okButton || ( - - )} - -
- - - - - - - - - - + )} + + + + ); } diff --git a/packages/components-library/src/tabs.tsx b/packages/components-library/src/tabs.tsx index 7b70a2543..fdaefdb28 100644 --- a/packages/components-library/src/tabs.tsx +++ b/packages/components-library/src/tabs.tsx @@ -30,10 +30,10 @@ export default function Tabs(props: TabsProps) { value={value} onValueChange={onChange} > - + {items.map((item) => ( @@ -52,10 +52,10 @@ export default function Tabs(props: TabsProps) { return ( - + {items.map((item) => ( diff --git a/packages/email-editor/.eslintrc.js b/packages/email-editor/.eslintrc.js new file mode 100644 index 000000000..5aec1858f --- /dev/null +++ b/packages/email-editor/.eslintrc.js @@ -0,0 +1,24 @@ +module.exports = { + env: { + browser: true, + }, + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react/recommended", + "prettier", + ], + plugins: ["react-hooks"], + ignorePatterns: ["src/components/ui/**", "dist/**", "tailwind.config.js"], + rules: { + "react/react-in-jsx-scope": "off", + "react-hooks/rules-of-hooks": "error", + "@typescript-eslint/no-explicit-any": "warn", + "react/display-name": "off", + }, + settings: { + react: { + version: "detect", + }, + }, +}; diff --git a/packages/email-editor/.gitignore b/packages/email-editor/.gitignore new file mode 100644 index 000000000..a4b426ea9 --- /dev/null +++ b/packages/email-editor/.gitignore @@ -0,0 +1,3 @@ +node_modules/ + +dist/ \ No newline at end of file diff --git a/packages/email-editor/CHANGELOG.md b/packages/email-editor/CHANGELOG.md new file mode 100644 index 000000000..14dc700cf --- /dev/null +++ b/packages/email-editor/CHANGELOG.md @@ -0,0 +1,13 @@ +# @courselit/email-editor + +## 0.1.1 + +### Patch Changes + +- dd5edd2: Rendering is exposed to the consumer of the package + +## 0.1.0 + +### Patch Changes + +- 56bc36a: Testing GH Action and changeset diff --git a/packages/email-editor/LICENSE b/packages/email-editor/LICENSE new file mode 100644 index 000000000..24d6d6137 --- /dev/null +++ b/packages/email-editor/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 CourseLit + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/email-editor/README.md b/packages/email-editor/README.md new file mode 100644 index 000000000..70051c93f --- /dev/null +++ b/packages/email-editor/README.md @@ -0,0 +1,56 @@ +# Introduction + +A WYSIWYG email editor by [CourseLit](https://courselit.app). + +## Installation + +The project depends of TailwindCSS, so you need to have it configured on your project, before installating this package. + +```sh +npm i @courselit/email-editor +``` + +### Importing the CSS + +#### 1. Tailwind v4 + +In your CSS file, add + +```css +@source "./node_modules/@courselit/email-editor"; +# ... remaining code ... +``` + +#### 2. Tailwind v3 + +In your tailwind config, add + +```js +module.exports = { + content: [ + // ... remaining code ... + "./node_modules/@courselit/email-editor", + ], + // ... remaining code ... +}; +``` + +## Tech Stack + +- [React](https://react.dev/) +- [TailwindCSS](https://tailwindcss.com/) +- [Shadcn/ui](https://ui.shadcn.com/) +- [React email](https://react.email/) + +## Usage + +To show the email editor + +```js +import { EmailEditor } from "@courselit/email-editor"; +import "@courselit/email-editor/styles.css"; + +export default App() { + return () +} +``` diff --git a/packages/email-editor/components.json b/packages/email-editor/components.json new file mode 100644 index 000000000..3307c8e41 --- /dev/null +++ b/packages/email-editor/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/packages/email-editor/package.json b/packages/email-editor/package.json new file mode 100644 index 000000000..a9ade76b1 --- /dev/null +++ b/packages/email-editor/package.json @@ -0,0 +1,73 @@ +{ + "name": "@courselit/email-editor", + "version": "0.1.1", + "description": "Email Editor by CourseLit", + "author": "CourseLit ", + "homepage": "https://github.com/codelitdev/courselit#readme", + "license": "MIT", + "main": "dist/index.mjs", + "types": "dist/index.d.ts", + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "exports": { + ".": "./dist/index.mjs", + "./styles.css": "./dist/index.css", + "./blocks": "./dist/blocks" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/codelitdev/courselit.git" + }, + "scripts": { + "test": "echo \"Error: run tests from root\" && exit 1", + "clean": "rimraf dist/", + "prepublishOnly": "pnpm run build", + "build": "tsup", + "tsc:build": "tsc", + "dev": "tsup --watch", + "check-types": "tsc --noEmit" + }, + "bugs": { + "url": "https://github.com/codelitdev/courselit/issues" + }, + "devDependencies": { + "@types/node": "^20.11.24", + "@types/react": "^18.0.0", + "autoprefixer": "^10.4.18", + "eslint": "^8.57.0", + "postcss": "^8.4.35", + "rimraf": "^4.1.1", + "tailwindcss": "^3.4.1", + "tailwindcss-animate": "^1.0.7", + "tsup": "6.6.0", + "typescript": "^4.9.5", + "typescript-eslint": "^7.4.0" + }, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-label": "^2.1.4", + "@radix-ui/react-popover": "^1.1.14", + "@radix-ui/react-select": "^2.2.2", + "@radix-ui/react-slider": "^1.3.2", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.5", + "@radix-ui/react-tooltip": "^1.2.7", + "@react-email/components": "^0.0.42", + "@react-email/render": "^1.1.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.515.0", + "tailwind-merge": "^3.3.1", + "tw-animate-css": "^1.2.8", + "uuid": "^11.1.0" + }, + "peerDependencies": { + "react": ">=18.0.0" + } +} diff --git a/packages/email-editor/postcss.config.js b/packages/email-editor/postcss.config.js new file mode 100644 index 000000000..67cdf1a55 --- /dev/null +++ b/packages/email-editor/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/email-editor/src/blocks/image/block.tsx b/packages/email-editor/src/blocks/image/block.tsx new file mode 100644 index 000000000..802d1dc4f --- /dev/null +++ b/packages/email-editor/src/blocks/image/block.tsx @@ -0,0 +1,75 @@ +import { EmailBlock } from "@/types/email-editor"; +import type { ImageBlockSettings } from "./types"; +import { Img, Section } from "@react-email/components"; +import { ImageIcon } from "lucide-react"; + +interface ImageBlockProps { + block: EmailBlock & { settings: ImageBlockSettings }; +} + +export function ImageBlock({ block }: ImageBlockProps) { + const { + src = "", + alt = "Image", + alignment = "left", + width = "auto", + height = "auto", + maxWidth = "100%", + borderRadius = "0px", + borderWidth, + borderStyle = "solid", + borderColor = "#e2e8f0", + // Common block settings + backgroundColor = "transparent", + // foregroundColor = "#000000", + paddingTop = "0px", + paddingBottom = "0px", + } = block.settings; + + return ( +
+
+ {src ? ( + {alt} + ) : ( +
+
+ +

+ Select an image from the settings panel +

+
+
+ )} +
+
+ ); +} diff --git a/packages/email-editor/src/blocks/image/index.ts b/packages/email-editor/src/blocks/image/index.ts new file mode 100644 index 000000000..b2acb8f5f --- /dev/null +++ b/packages/email-editor/src/blocks/image/index.ts @@ -0,0 +1,10 @@ +import { ImageBlock as block } from "./block"; +import { ImageSettings as settings } from "./settings"; +import { metadata } from "./metadata"; +import type { BlockComponent } from "@/types/block-registry"; + +export const ImageBlock: BlockComponent = { + block, + settings, + metadata, +}; diff --git a/packages/email-editor/src/blocks/image/metadata.ts b/packages/email-editor/src/blocks/image/metadata.ts new file mode 100644 index 000000000..896eacc2f --- /dev/null +++ b/packages/email-editor/src/blocks/image/metadata.ts @@ -0,0 +1,35 @@ +import { ImageIcon } from "lucide-react"; +import type { BlockMetadata } from "@/types/block-registry"; + +export const metadata: BlockMetadata = { + name: "image", + displayName: "Image", + description: "Add images with customizable sizing and alignment", + icon: ImageIcon, + docs: { + settings: { + src: "The URL of the image to display. Format: https://example.com/image.jpg", + alt: "[Optional] Alternative text for the image for accessibility. Format: string", + alignment: + "[Optional] The alignment of the image. Range: left, center, right. Default: left", + width: "[Optional] The width of the image. Range: auto, 100px, 200px, 300px, 400px, 500px, 100%. Default: auto", + height: "[Optional] The height of the image. Range: auto, 100px, 150px, 200px, 250px, 300px. Default: auto", + maxWidth: + "[Optional] The maximum width of the image. Range: 100%, 75%, 50%, 25%, none. Default: 100%", + borderRadius: + "[Optional] The border radius of the image. Format: 0px. Range: 0-250. Default: 0px", + borderWidth: + "[Optional] The border width of the image. Format: 0px. Range: 0-10. Default: 0px", + borderStyle: + "[Optional] The border style of the image. Range: solid, dashed, dotted. Default: solid", + borderColor: + "[Optional] The border color of the image. Format: #e2e8f0. Default: #e2e8f0", + backgroundColor: + "[Optional] The background color of the image block. Format: #000000. Default: transparent", + paddingTop: + "[Optional] The top padding of the image block. Format: 0px. Range: 0-100. Default: 0px", + paddingBottom: + "[Optional] The bottom padding of the image block. Format: 0px. Range: 0-100. Default: 0px", + }, + }, +}; diff --git a/packages/email-editor/src/blocks/image/settings.tsx b/packages/email-editor/src/blocks/image/settings.tsx new file mode 100644 index 000000000..7ea446b01 --- /dev/null +++ b/packages/email-editor/src/blocks/image/settings.tsx @@ -0,0 +1,291 @@ +import type { EmailBlock, EmailStyle } from "@/types/email-editor"; +import type { ImageBlockSettings } from "./types"; +import { SettingsInput } from "@/components/settings/settings-input"; +import { SettingsSelect } from "@/components/settings/settings-select"; +import { SettingsColorPicker } from "@/components/settings/settings-color-picker"; +import { SettingsSlider } from "@/components/settings/settings-slider"; +import { SettingsSection } from "@/components/settings/settings-section"; +import { Button } from "@/components/ui/button"; +import { Upload } from "lucide-react"; + +interface ImageSettingsProps { + block: Required & { settings: ImageBlockSettings }; + style?: EmailStyle; + updateBlock: (id: string, content: Partial) => void; +} + +export function ImageSettings({ + block, + style, + updateBlock, +}: ImageSettingsProps) { + const handleSettingChange = (key: string, value: any) => { + updateBlock(block.id, { + settings: { + ...block.settings, + [key]: value, + }, + }); + }; + + const handleImageUpload = () => { + const url = window.prompt("Enter image URL:", block.settings.src || ""); + if (url !== null) { + handleSettingChange("src", url); + } + }; + + // Helper function to convert px string to number + const pxToNumber = ( + value: string | undefined, + defaultValue: number, + ): number => { + if (!value) return defaultValue; + const match = value.match(/^(\d+)px$/); + return match ? Number.parseInt(match[1], 10) : defaultValue; + }; + + // Get numeric values from settings + const borderRadius = pxToNumber(block.settings.borderRadius, 0); + const borderWidth = pxToNumber(block.settings.borderWidth, 0); + + // Sample images + const sampleImages = [ + "https://images.unsplash.com/photo-1557804506-669a67965ba0?w=400&h=300&fit=crop", + "https://images.unsplash.com/photo-1522202176988-66273c2fd55f?w=400&h=300&fit=crop", + "https://images.unsplash.com/photo-1542744173-8e7e53415bb0?w=400&h=300&fit=crop", + ]; + + // Width options + const widthOptions = [ + { value: "auto", label: "Auto" }, + { value: "100px", label: "100px" }, + { value: "200px", label: "200px" }, + { value: "300px", label: "300px" }, + { value: "400px", label: "400px" }, + { value: "500px", label: "500px" }, + { value: "100%", label: "100%" }, + ]; + + // Height options + const heightOptions = [ + { value: "auto", label: "Auto" }, + { value: "100px", label: "100px" }, + { value: "150px", label: "150px" }, + { value: "200px", label: "200px" }, + { value: "250px", label: "250px" }, + { value: "300px", label: "300px" }, + ]; + + // Max width options + const maxWidthOptions = [ + { value: "100%", label: "100%" }, + { value: "75%", label: "75%" }, + { value: "50%", label: "50%" }, + { value: "25%", label: "25%" }, + { value: "none", label: "None" }, + ]; + + // Alignment options + const alignmentOptions = [ + { value: "left", label: "Left" }, + { value: "center", label: "Center" }, + { value: "right", label: "Right" }, + ]; + + // Border style options + const borderStyleOptions = [ + { value: "solid", label: "Solid" }, + { value: "dashed", label: "Dashed" }, + { value: "dotted", label: "Dotted" }, + ]; + + return ( +
+ {/* Image Source */} +
+
+ handleSettingChange("src", value)} + placeholder="https://example.com/image.jpg" + className="flex-1" + /> + +
+
+ + {/* Sample Images */} +
+ +
+ {sampleImages.map((url, index) => ( + + ))} +
+
+ + {/* Alt Text */} + handleSettingChange("alt", value)} + placeholder="Describe the image" + defaultValue="" + /> + + {/* Alignment */} + handleSettingChange("alignment", value)} + options={alignmentOptions} + defaultValue="left" + /> + + {/* Dimensions */} + + handleSettingChange("width", value)} + options={widthOptions} + defaultValue="auto" + /> + + handleSettingChange("height", value)} + options={heightOptions} + defaultValue="auto" + /> + + handleSettingChange("maxWidth", value)} + options={maxWidthOptions} + defaultValue="100%" + /> + + + {/* Border */} + + + handleSettingChange("borderRadius", `${value}px`) + } + min={0} + max={50} + defaultValue={0} + /> + + + handleSettingChange("borderWidth", `${value}px`) + } + min={0} + max={20} + defaultValue={0} + /> + + + handleSettingChange("borderStyle", value) + } + options={borderStyleOptions} + defaultValue="solid" + /> + + + handleSettingChange("borderColor", value) + } + defaultValue="#e2e8f0" + /> + + + {/* Common Block Settings */} + + + handleSettingChange("backgroundColor", value) + } + defaultValue="transparent" + /> + + + handleSettingChange("paddingTop", `${value}px`) + } + min={0} + max={100} + defaultValue={pxToNumber( + style?.structure.section.padding?.y, + 16, + )} + /> + + + handleSettingChange("paddingBottom", `${value}px`) + } + min={0} + max={100} + defaultValue={pxToNumber( + style?.structure.section.padding?.y, + 16, + )} + /> + +
+ ); +} diff --git a/packages/email-editor/src/blocks/image/types.ts b/packages/email-editor/src/blocks/image/types.ts new file mode 100644 index 000000000..b0156365e --- /dev/null +++ b/packages/email-editor/src/blocks/image/types.ts @@ -0,0 +1,15 @@ +import type { CommonBlockSettings } from "@/types/email-editor"; + +export interface ImageBlockSettings extends CommonBlockSettings { + src: string; + alt?: string; + alignment?: "left" | "center" | "right"; + width?: string; + height?: string; + maxWidth?: string; + borderRadius?: string; + borderWidth?: string; + borderStyle?: string; + borderColor?: string; + padding?: string; +} diff --git a/packages/email-editor/src/blocks/index.ts b/packages/email-editor/src/blocks/index.ts new file mode 100644 index 000000000..3690c4160 --- /dev/null +++ b/packages/email-editor/src/blocks/index.ts @@ -0,0 +1,4 @@ +export { TextBlock as Text } from "./text"; +export { SeparatorBlock as Separator } from "./separator"; +export { ImageBlock as Image } from "./image"; +export { LinkBlock as Link } from "./link"; diff --git a/packages/email-editor/src/blocks/link/block.tsx b/packages/email-editor/src/blocks/link/block.tsx new file mode 100644 index 000000000..9be0cfccf --- /dev/null +++ b/packages/email-editor/src/blocks/link/block.tsx @@ -0,0 +1,117 @@ +import type { EmailBlock, EmailStyle } from "@/types/email-editor"; +import type { LinkBlockSettings } from "./types"; +import { Link, Section, Button } from "@react-email/components"; + +interface LinkBlockProps { + block: EmailBlock & { settings: LinkBlockSettings }; + style?: EmailStyle; +} + +export function LinkBlock({ block, style }: LinkBlockProps) { + const { + text = "Link Text", + url = "#", + alignment = "left", + fontSize = block.settings.fontSize || + style?.typography.link.fontSize || + "16px", + textDecoration = block.settings.textDecoration || + style?.typography.link.textDecoration || + "underline", + isButton = false, + textColor = style?.colors.accent, + buttonColor = style?.colors.accent, + buttonTextColor = style?.colors.accentForeground, + buttonBorderRadius = "4px", + buttonPaddingX = "16px", + buttonPaddingY = "8px", + buttonBorderWidth = "0px", + buttonBorderStyle = "solid", + buttonBorderColor = "#0284c7", + backgroundColor = "transparent", + // foregroundColor = style?.colors.accent || "#000000", + paddingTop = style?.structure.section.padding?.y, + paddingBottom = style?.structure.section.padding?.y, + } = block.settings; + + // If it's a button, use the Button component + if (isButton) { + return ( +
+
+ +
+
+ ); + } + + // Otherwise, use the Link component + return ( +
+
+ + {text} + +
+
+ ); +} diff --git a/packages/email-editor/src/blocks/link/index.ts b/packages/email-editor/src/blocks/link/index.ts new file mode 100644 index 000000000..2ed00d555 --- /dev/null +++ b/packages/email-editor/src/blocks/link/index.ts @@ -0,0 +1,10 @@ +import { LinkBlock as block } from "./block"; +import { LinkSettings as settings } from "./settings"; +import { metadata } from "./metadata"; +import type { BlockComponent } from "@/types/block-registry"; + +export const LinkBlock: BlockComponent = { + block, + settings, + metadata, +}; diff --git a/packages/email-editor/src/blocks/link/metadata.ts b/packages/email-editor/src/blocks/link/metadata.ts new file mode 100644 index 000000000..4a3076648 --- /dev/null +++ b/packages/email-editor/src/blocks/link/metadata.ts @@ -0,0 +1,47 @@ +import { Link } from "lucide-react"; +import type { BlockMetadata } from "@/types/block-registry"; + +export const metadata: BlockMetadata = { + name: "link", + displayName: "Link", + description: "Add a hyperlink or call-to-action button", + icon: Link, + docs: { + settings: { + text: "The text to display for the link or button", + url: "The URL that the link or button will navigate to. Format: https://example.com", + alignment: + "[Optional] The alignment of the link or button. Range: left, center, right. Default: left", + isButton: + "[Optional] Whether to display as a button instead of a text link. Default: false", + textColor: + "[Optional] The color of the link text (when not in button mode). Format: #000000", + fontSize: + "[Optional] The font size of the link text (when not in button mode). Range: 12px, 14px, 16px, 18px, 20px, 24px. Default: 16px", + textDecoration: + "[Optional] The text decoration of the link (when not in button mode). Range: underline, none, line-through. Default: underline", + buttonColor: + "[Optional] The background color of the button (when in button mode). Format: #000000", + buttonTextColor: + "[Optional] The text color of the button (when in button mode). Format: #000000", + buttonBorderRadius: + "[Optional] The border radius of the button (when in button mode). Format: 4px. Range: 0-50. Default: 4px", + buttonPaddingX: + "[Optional] The horizontal padding of the button (when in button mode). Format: 16px. Range: 0-100. Default: 16px", + buttonPaddingY: + "[Optional] The vertical padding of the button (when in button mode). Format: 8px. Range: 0-50. Default: 8px", + buttonBorderWidth: + "[Optional] The border width of the button (when in button mode). Format: 0px. Range: 0-10. Default: 0px", + buttonBorderStyle: + "[Optional] The border style of the button (when in button mode). Range: solid, dashed, dotted. Default: solid", + buttonBorderColor: + "[Optional] The border color of the button (when in button mode). Format: #000000", + backgroundColor: + "[Optional] The background color of the link block. Format: #000000", + paddingTop: + "[Optional] The top padding of the link block. Format: 0px. Range: 0-100. Default: 0px", + paddingBottom: + "[Optional] The bottom padding of the link block. Format: 0px. Range: 0-100. Default: 0px", + }, + }, +}; diff --git a/packages/email-editor/src/blocks/link/settings.tsx b/packages/email-editor/src/blocks/link/settings.tsx new file mode 100644 index 000000000..05089672c --- /dev/null +++ b/packages/email-editor/src/blocks/link/settings.tsx @@ -0,0 +1,178 @@ +import type { EmailBlock, EmailStyle } from "@/types/email-editor"; +import type { LinkBlockSettings } from "./types"; +import { SettingsInput } from "@/components/settings/settings-input"; +import { SettingsSelect } from "@/components/settings/settings-select"; +import { SettingsColorPicker } from "@/components/settings/settings-color-picker"; +import { SettingsSlider } from "@/components/settings/settings-slider"; +import { SettingsSection } from "@/components/settings/settings-section"; +import { SettingsSwitch } from "@/components/settings/settings-switch"; + +interface LinkSettingsProps { + block: Required & { settings: LinkBlockSettings }; + style?: EmailStyle; + updateBlock: (id: string, content: Partial) => void; +} + +export function LinkSettings({ block, style, updateBlock }: LinkSettingsProps) { + const handleSettingChange = (key: string, value: any) => { + updateBlock(block.id, { + settings: { + ...block.settings, + [key]: value, + }, + }); + }; + + // Helper function to convert px string to number + const pxToNumber = ( + value: string | undefined, + defaultValue: number, + ): number => { + if (!value) return defaultValue; + const match = value.match(/^(\d+)px$/); + return match ? Number.parseInt(match[1], 10) : defaultValue; + }; + + // Font size options + const fontSizeOptions = [ + { value: "12px", label: "12px" }, + { value: "14px", label: "14px" }, + { value: "16px", label: "16px" }, + { value: "18px", label: "18px" }, + { value: "20px", label: "20px" }, + { value: "24px", label: "24px" }, + { value: "28px", label: "28px" }, + { value: "32px", label: "32px" }, + ]; + + // Alignment options + const alignmentOptions = [ + { value: "left", label: "Left" }, + { value: "center", label: "Center" }, + { value: "right", label: "Right" }, + ]; + + // Text decoration options + const textDecorationOptions = [ + { value: "none", label: "None" }, + { value: "underline", label: "Underline" }, + { value: "overline", label: "Overline" }, + { value: "line-through", label: "Line Through" }, + ]; + + return ( +
+ handleSettingChange("text", value)} + placeholder="Enter link text" + /> + + handleSettingChange("url", value)} + placeholder="https://example.com" + /> + + handleSettingChange("alignment", value)} + options={alignmentOptions} + defaultValue="left" + /> + + handleSettingChange("textColor", value)} + defaultValue="#0284c7" + /> + + handleSettingChange("fontSize", value)} + options={fontSizeOptions} + defaultValue="16px" + /> + + + handleSettingChange("textDecoration", value) + } + options={textDecorationOptions} + defaultValue="underline" + /> + + + handleSettingChange("isButton", value) + } + /> + + {/* Common Block Settings */} + + + handleSettingChange("backgroundColor", value) + } + defaultValue="transparent" + /> + + + handleSettingChange("foregroundColor", value) + } + defaultValue="#000000" + /> + + + handleSettingChange("paddingTop", `${value}px`) + } + min={0} + max={100} + defaultValue={pxToNumber( + style?.structure.section.padding?.y, + 16, + )} + /> + + + handleSettingChange("paddingBottom", `${value}px`) + } + min={0} + max={100} + defaultValue={pxToNumber( + style?.structure.section.padding?.y, + 16, + )} + /> + +
+ ); +} diff --git a/packages/email-editor/src/blocks/link/types.ts b/packages/email-editor/src/blocks/link/types.ts new file mode 100644 index 000000000..62ab54f8e --- /dev/null +++ b/packages/email-editor/src/blocks/link/types.ts @@ -0,0 +1,21 @@ +import type { CommonBlockSettings } from "@/types/email-editor"; + +export interface LinkBlockSettings extends CommonBlockSettings { + text: string; + url: string; + alignment?: "left" | "center" | "right"; + textColor?: string; + fontSize?: string; + textDecoration?: string; + + // Button mode settings + isButton?: boolean; + buttonColor?: string; + buttonTextColor?: string; + buttonBorderRadius?: string; + buttonPaddingX?: string; + buttonPaddingY?: string; + buttonBorderWidth?: string; + buttonBorderStyle?: string; + buttonBorderColor?: string; +} diff --git a/packages/email-editor/src/blocks/separator/block.tsx b/packages/email-editor/src/blocks/separator/block.tsx new file mode 100644 index 000000000..ad5ff60b4 --- /dev/null +++ b/packages/email-editor/src/blocks/separator/block.tsx @@ -0,0 +1,42 @@ +import type { EmailBlock, EmailStyle } from "@/types/email-editor"; +import type { SeparatorBlockSettings } from "./types"; +import { Hr, Section } from "@react-email/components"; + +interface SeparatorBlockProps { + block: EmailBlock & { settings: SeparatorBlockSettings }; + style?: EmailStyle; +} + +export function SeparatorBlock({ block, style }: SeparatorBlockProps) { + const { + color = style?.colors?.border, + thickness = "1px", + style: borderStyle = "solid", + // Common block settings + paddingTop = style?.structure?.section?.padding?.y, + paddingBottom = style?.structure?.section?.padding?.y, + backgroundColor = "transparent", + } = block.settings; + + return ( +
+
+
+
+
+ ); +} diff --git a/packages/email-editor/src/blocks/separator/index.ts b/packages/email-editor/src/blocks/separator/index.ts new file mode 100644 index 000000000..ee70884bc --- /dev/null +++ b/packages/email-editor/src/blocks/separator/index.ts @@ -0,0 +1,10 @@ +import { SeparatorBlock as block } from "./block"; +import { SeparatorSettings as settings } from "./settings"; +import { metadata } from "./metadata"; +import type { BlockComponent } from "@/types/block-registry"; + +export const SeparatorBlock: BlockComponent = { + block, + settings, + metadata, +}; diff --git a/packages/email-editor/src/blocks/separator/metadata.ts b/packages/email-editor/src/blocks/separator/metadata.ts new file mode 100644 index 000000000..b18b47c12 --- /dev/null +++ b/packages/email-editor/src/blocks/separator/metadata.ts @@ -0,0 +1,23 @@ +import { Minus } from "lucide-react"; +import type { BlockMetadata } from "@/types/block-registry"; + +export const metadata: BlockMetadata = { + name: "separator", + displayName: "Separator", + description: "Horizontal line to separate content sections", + icon: Minus, + docs: { + settings: { + color: "[Optional] The color of the separator line. Format: #000000. Default: transparent", + thickness: + "[Optional] The thickness of the separator line. Format: 1px. Range: 1-20. Default: 1px", + style: "[Optional] The style of the separator line. Range: solid, dashed, dotted, double. Default: solid", + backgroundColor: + "[Optional] The background color of the separator block. Format: #000000. Default: transparent", + paddingTop: + "[Optional] The top padding of the separator block. Format: 0px. Range: 0-100. Default: 0px", + paddingBottom: + "[Optional] The bottom padding of the separator block. Format: 0px. Range: 0-100. Default: 0px", + }, + }, +}; diff --git a/packages/email-editor/src/blocks/separator/settings.tsx b/packages/email-editor/src/blocks/separator/settings.tsx new file mode 100644 index 000000000..0091a2f82 --- /dev/null +++ b/packages/email-editor/src/blocks/separator/settings.tsx @@ -0,0 +1,135 @@ +import type { EmailBlock, EmailStyle } from "@/types/email-editor"; +import type { SeparatorBlockSettings } from "./types"; +import { SettingsColorPicker } from "@/components/settings/settings-color-picker"; +import { SettingsSlider } from "@/components/settings/settings-slider"; +import { SettingsSelect } from "@/components/settings/settings-select"; +import { SettingsSection } from "@/components/settings/settings-section"; + +interface SeparatorSettingsProps { + block: Required & { settings: SeparatorBlockSettings }; + style?: EmailStyle; + updateBlock: (id: string, content: Partial) => void; +} + +export function SeparatorSettings({ + block, + style, + updateBlock, +}: SeparatorSettingsProps) { + const handleSettingChange = (key: string, value: any) => { + updateBlock(block.id, { + settings: { + ...block.settings, + [key]: value, + }, + }); + }; + + // Helper function to convert px string to number + const pxToNumber = ( + value: string | undefined, + defaultValue: number, + ): number => { + if (!value) return defaultValue; + const match = value.match(/^(\d+)px$/); + return match ? Number.parseInt(match[1], 10) : defaultValue; + }; + + // Get numeric values from settings + const thickness = pxToNumber(block.settings.thickness, 1); + + // Style options + const styleOptions = [ + { value: "solid", label: "Solid" }, + { value: "dashed", label: "Dashed" }, + { value: "dotted", label: "Dotted" }, + { value: "double", label: "Double" }, + ]; + + return ( +
+ handleSettingChange("color", value)} + defaultValue="#e2e8f0" + /> + + + handleSettingChange("thickness", `${value}px`) + } + min={1} + max={20} + defaultValue={1} + /> + + handleSettingChange("style", value)} + options={styleOptions} + defaultValue="solid" + /> + + {/* Common Block Settings */} + + + handleSettingChange("backgroundColor", value) + } + defaultValue="transparent" + /> + + + handleSettingChange("foregroundColor", value) + } + defaultValue="#000000" + /> + + + handleSettingChange("paddingTop", `${value}px`) + } + min={0} + max={100} + defaultValue={pxToNumber( + style?.structure.section.padding?.y, + 16, + )} + /> + + + handleSettingChange("paddingBottom", `${value}px`) + } + min={0} + max={100} + defaultValue={pxToNumber( + style?.structure.section.padding?.y, + 16, + )} + /> + +
+ ); +} diff --git a/packages/email-editor/src/blocks/separator/types.ts b/packages/email-editor/src/blocks/separator/types.ts new file mode 100644 index 000000000..d1e699c1b --- /dev/null +++ b/packages/email-editor/src/blocks/separator/types.ts @@ -0,0 +1,7 @@ +import type { CommonBlockSettings } from "@/types/email-editor"; + +export interface SeparatorBlockSettings extends CommonBlockSettings { + color?: string; + thickness?: string; + style?: "solid" | "dashed" | "dotted" | "double"; +} diff --git a/packages/email-editor/src/blocks/text/block.tsx b/packages/email-editor/src/blocks/text/block.tsx new file mode 100644 index 000000000..2d0a92d7c --- /dev/null +++ b/packages/email-editor/src/blocks/text/block.tsx @@ -0,0 +1,150 @@ +import { Section, Markdown } from "@react-email/components"; +import type { EmailBlock, EmailStyle } from "@/types/email-editor"; +import type { TextBlockSettings } from "./types"; + +interface TextBlockProps { + block: EmailBlock & { settings: TextBlockSettings }; + style?: EmailStyle; + selectedBlockId?: string | null; +} + +export function TextBlock({ block, style, selectedBlockId }: TextBlockProps) { + const isSelected = selectedBlockId === block.id; + + // Get common block settings + const { + backgroundColor = "transparent", + foregroundColor = style?.colors.foreground || "#000000", + paddingTop = style?.structure.section.padding?.y, + paddingBottom = style?.structure.section.padding?.y, + } = block.settings; + + // Ensure there's always some content to make the block selectable + const content = + block.settings.content || (isSelected ? "" : "Text content"); + + return ( +
+
+ + {content} + +
+
+ ); +} diff --git a/packages/email-editor/src/blocks/text/index.ts b/packages/email-editor/src/blocks/text/index.ts new file mode 100644 index 000000000..417552685 --- /dev/null +++ b/packages/email-editor/src/blocks/text/index.ts @@ -0,0 +1,10 @@ +import { TextBlock as block } from "./block"; +import { TextSettings as settings } from "./settings"; +import { metadata } from "./metadata"; +import type { BlockComponent } from "@/types/block-registry"; + +export const TextBlock: BlockComponent = { + block, + settings, + metadata, +}; diff --git a/packages/email-editor/src/blocks/text/metadata.ts b/packages/email-editor/src/blocks/text/metadata.ts new file mode 100644 index 000000000..6adb8fb40 --- /dev/null +++ b/packages/email-editor/src/blocks/text/metadata.ts @@ -0,0 +1,31 @@ +import { Type } from "lucide-react"; +import type { BlockMetadata } from "@/types/block-registry"; + +export const metadata: BlockMetadata = { + name: "text", + displayName: "Text", + description: "Rich text content with formatting options", + icon: Type, + docs: { + settings: { + content: + "The text content to display. Use markdown to format the text.", + alignment: + "[Optional] The alignment of the text. Range: left, center, right, justify. Default: left", + fontFamily: + "[Optional] The font family to use. Range: Arial, Helvetica, Verdana, Georgia, Times New Roman, Monospace. Default: Arial, sans-serif", + fontSize: + "The font size to use. Format: 12px. Range: 12-48. Default: 16px", + lineHeight: + "The line height to use. Values: 1, 1.2, 1.5, 1.8, 2. Default: 1.5", + foregroundColor: + "[Optional] The color of the text. Format: #000000", + backgroundColor: + "[Optional] The background color of the text. Format: #000000", + paddingTop: + "[Optional] The top padding of the text. Format: 0px. Range: 0 - 100", + paddingBottom: + "[Optional] The bottom padding of the text. Format: 0px. Range: 0 - 100", + }, + }, +}; diff --git a/packages/email-editor/src/blocks/text/settings.tsx b/packages/email-editor/src/blocks/text/settings.tsx new file mode 100644 index 000000000..40e5b0049 --- /dev/null +++ b/packages/email-editor/src/blocks/text/settings.tsx @@ -0,0 +1,184 @@ +import type { EmailBlock, EmailStyle } from "@/types/email-editor"; +import type { TextBlockSettings } from "./types"; +import { SettingsTextarea } from "@/components/settings/settings-textarea"; +import { SettingsSelect } from "@/components/settings/settings-select"; +import { SettingsColorPicker } from "@/components/settings/settings-color-picker"; +import { SettingsSlider } from "@/components/settings/settings-slider"; +import { SettingsSection } from "@/components/settings/settings-section"; + +interface TextSettingsProps { + block: Required & { settings: TextBlockSettings }; + style?: EmailStyle; + updateBlock: (id: string, content: Partial) => void; +} + +export function TextSettings({ block, style, updateBlock }: TextSettingsProps) { + const handleSettingChange = (key: string, value: any) => { + updateBlock(block.id, { + settings: { + ...block.settings, + [key]: value, + }, + }); + }; + + // Helper function to convert px string to number + const pxToNumber = ( + value: string | undefined, + defaultValue: number, + ): number => { + if (!value) return defaultValue; + const match = value.match(/^(\d+)px$/); + return match ? Number.parseInt(match[1], 10) : defaultValue; + }; + + // Font size options + const fontSizeOptions = [ + { value: "12px", label: "12px" }, + { value: "14px", label: "14px" }, + { value: "16px", label: "16px" }, + { value: "18px", label: "18px" }, + { value: "20px", label: "20px" }, + { value: "24px", label: "24px" }, + { value: "28px", label: "28px" }, + { value: "32px", label: "32px" }, + { value: "36px", label: "36px" }, + { value: "42px", label: "42px" }, + { value: "48px", label: "48px" }, + ]; + + // Font family options + const fontFamilyOptions = [ + { value: "Arial, sans-serif", label: "Arial" }, + { value: "Helvetica, sans-serif", label: "Helvetica" }, + { value: "Georgia, serif", label: "Georgia" }, + { value: "'Times New Roman', serif", label: "Times New Roman" }, + { value: "Verdana, sans-serif", label: "Verdana" }, + { value: "monospace", label: "Monospace" }, + ]; + + // Line height options + const lineHeightOptions = [ + { value: "1", label: "1" }, + { value: "1.2", label: "1.2" }, + { value: "1.5", label: "1.5" }, + { value: "1.8", label: "1.8" }, + { value: "2", label: "2" }, + ]; + + // Alignment options + const alignmentOptions = [ + { value: "left", label: "Left" }, + { value: "center", label: "Center" }, + { value: "right", label: "Right" }, + { value: "justify", label: "Justify" }, + ]; + + return ( +
+ handleSettingChange("content", value)} + placeholder="Enter your text here" + rows={5} + /> + + handleSettingChange("alignment", value)} + options={alignmentOptions} + defaultValue="left" + /> + + handleSettingChange("fontFamily", value)} + options={fontFamilyOptions} + defaultValue="" + /> + + handleSettingChange("fontSize", value)} + options={fontSizeOptions} + defaultValue="16px" + /> + + handleSettingChange("lineHeight", value)} + options={lineHeightOptions} + defaultValue="1.5" + /> + + {/* handleSettingChange("textColor", value)} + defaultValue="#000000" + /> */} + + {/* Common Block Settings */} + + + handleSettingChange("backgroundColor", value) + } + defaultValue="transparent" + /> + + + handleSettingChange("foregroundColor", value) + } + defaultValue="#000000" + /> + + + handleSettingChange("paddingTop", `${value}px`) + } + min={0} + max={100} + defaultValue={pxToNumber( + style?.structure.section.padding?.y, + 16, + )} + /> + + + handleSettingChange("paddingBottom", `${value}px`) + } + min={0} + max={100} + defaultValue={pxToNumber( + style?.structure.section.padding?.y, + 16, + )} + /> + +
+ ); +} diff --git a/packages/email-editor/src/blocks/text/types.ts b/packages/email-editor/src/blocks/text/types.ts new file mode 100644 index 000000000..1551b3733 --- /dev/null +++ b/packages/email-editor/src/blocks/text/types.ts @@ -0,0 +1,9 @@ +import type { CommonBlockSettings } from "@/types/email-editor"; + +export interface TextBlockSettings extends CommonBlockSettings { + content: string; + alignment?: "left" | "center" | "right" | "justify"; + fontFamily?: string; + fontSize?: `${number}px`; + lineHeight?: `${number}`; +} diff --git a/packages/email-editor/src/components/add-block-button.tsx b/packages/email-editor/src/components/add-block-button.tsx new file mode 100644 index 000000000..494a9ae67 --- /dev/null +++ b/packages/email-editor/src/components/add-block-button.tsx @@ -0,0 +1,73 @@ +import { useState } from "react"; +import { Plus } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import type { BlockRegistry } from "../types/block-registry"; + +interface AddBlockButtonProps { + position: "above" | "below"; + index: number; + addBlock: (blockType: string, index: number) => void; + blockRegistry: BlockRegistry; +} + +export function AddBlockButton({ + position, + index, + addBlock, + blockRegistry, +}: AddBlockButtonProps) { + const [isOpen, setIsOpen] = useState(false); + + const blockTypes = Object.values(blockRegistry).map((block) => ({ + type: block.metadata.name, + icon: block.metadata.icon, + label: block.metadata.displayName, + description: block.metadata.description, + })); + + const handleAddBlock = (blockType: string) => { + addBlock(blockType, index); + setIsOpen(false); + }; + + return ( + + + + + +
+ {blockTypes.map((blockType) => ( + + ))} +
+
+
+ ); +} diff --git a/packages/email-editor/src/components/block-settings-panel.tsx b/packages/email-editor/src/components/block-settings-panel.tsx new file mode 100644 index 000000000..d909b07c9 --- /dev/null +++ b/packages/email-editor/src/components/block-settings-panel.tsx @@ -0,0 +1,123 @@ +import { X, ChevronRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { EmailSettings } from "./email-settings"; +import type { Email, EmailStyle } from "../types/email-editor"; +import type { BlockRegistry } from "../types/block-registry"; +import type { EmailBlock } from "../types/email-editor"; + +interface BlockSettingsPanelProps { + blockId: string | null; + email: Email; + setSelectedBlockId: (id: string | null) => void; + blockRegistry: BlockRegistry; + updateEmail: (email: Email) => void; + updateEmailStyle: (style: Partial) => void; + updateBlock: (id: string, content: Partial) => void; +} + +export function BlockSettingsPanel({ + blockId, + email, + setSelectedBlockId, + blockRegistry, + updateEmail, + updateEmailStyle, + updateBlock, +}: BlockSettingsPanelProps) { + if (!blockId) { + return ( +
+
+

Email Settings

+ +
+
+ +
+
+ ); + } + + const block = email.content.find((b) => b.id === blockId); + + if (!block) return null; + + const blockComponent = blockRegistry[block.blockType]; + if (!blockComponent) { + return ( +
+
+

Unknown Block

+ +
+
+

+ Block type "{block.blockType}" not found in + registry. +

+
+
+ ); + } + + const SettingsComponent = blockComponent.settings; + const capitalizedBlockName = blockComponent.metadata.displayName; + + const handleClose = (e: React.MouseEvent) => { + e.stopPropagation(); + setSelectedBlockId(null); + }; + + const handleEmailBreadcrumbClick = () => { + setSelectedBlockId(null); + }; + + return ( +
+
+
+ + + {capitalizedBlockName} +
+ +
+
+ +
+
+ ); +} diff --git a/packages/email-editor/src/components/block-wrapper.tsx b/packages/email-editor/src/components/block-wrapper.tsx new file mode 100644 index 000000000..9c74b496a --- /dev/null +++ b/packages/email-editor/src/components/block-wrapper.tsx @@ -0,0 +1,212 @@ +import { useState, useRef } from "react"; +import type { EmailBlock, EmailStyle } from "@/types/email-editor"; +import type { BlockRegistry } from "../types/block-registry"; +import { AddBlockButton } from "./add-block-button"; +import { Trash, Copy, ChevronUp, ChevronDown } from "lucide-react"; + +interface BlockWrapperProps { + block: Required; + index: number; + isFirst: boolean; + isLast: boolean; + isFixed?: boolean; + style?: EmailStyle; + blockRegistry: BlockRegistry; + selectedBlockId: string | null; + setSelectedBlockId: (id: string | null) => void; + deleteBlock: (id: string) => void; + moveBlock: (id: string, direction: "up" | "down") => void; + duplicateBlock: (id: string) => void; + movingBlockId: string | null; + addBlock: (blockType: string, index: number) => void; + totalBlocks: number; +} + +export function BlockWrapper({ + block, + index, + isFirst, + isLast, + isFixed = false, + style, + blockRegistry, + selectedBlockId, + setSelectedBlockId, + deleteBlock, + moveBlock, + duplicateBlock, + movingBlockId, + addBlock, + totalBlocks, +}: BlockWrapperProps) { + const [isHovered, setIsHovered] = useState(false); + const [isControlsHovered, setIsControlsHovered] = useState(false); + const controlsRef = useRef(null); + const blockRef = useRef(null); + + const isMoving = movingBlockId === block.id; + const isSelected = selectedBlockId === block.id; + + // Handle showing controls when either the block or controls are hovered + const showControls = isHovered || isControlsHovered; + + const handleBlockClick = () => { + setSelectedBlockId(block.id); + }; + + // Handle mouse enter/leave for the controls panel + const handleControlsMouseEnter = () => { + setIsControlsHovered(true); + }; + + const handleControlsMouseLeave = () => { + setIsControlsHovered(false); + }; + + const renderBlock = () => { + const blockComponent = blockRegistry[block.blockType]; + if (!blockComponent) { + return
Unknown block type: {block.blockType}
; + } + + const BlockComponent = blockComponent.block; + return ( + + ); + }; + + // Calculate if move buttons should be disabled + const canMoveUp = !isFixed && index > 1; // Can't move into first position (index 0) + const canMoveDown = !isFixed && index < totalBlocks - 2; // Can't move into last position + + // Check if we should show any controls at all + const hasAnyControls = !isFixed || (!isFirst && !isLast); + + return ( +
+ {/* Block content */} +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={handleBlockClick} + > + {/* Block content */} +
{renderBlock()}
+ + {/* Bottom border with add button on hover */} + {!isLast && ( +
+
e.stopPropagation()} + > + +
+
+ )} + + {/* Control buttons overlay - positioned inside the block */} + {showControls && !isMoving && hasAnyControls && ( +
e.stopPropagation()} + > + {/* Delete button - disabled for fixed blocks */} + {!isFixed && ( + + )} + + {/* Duplicate button - only for non-first and non-last blocks */} + {!isFirst && !isLast && ( + + )} + + {/* Move up button - disabled for fixed blocks and when can't move up */} + {canMoveUp && ( + + )} + + {/* Move down button - disabled for fixed blocks and when can't move down */} + {canMoveDown && ( + + )} +
+ )} + + {/* Moving indicator */} + {isMoving && ( +
+
+ Moving... +
+
+ )} +
+
+ ); +} diff --git a/packages/email-editor/src/components/email-editor.tsx b/packages/email-editor/src/components/email-editor.tsx new file mode 100644 index 000000000..1eaf3ad13 --- /dev/null +++ b/packages/email-editor/src/components/email-editor.tsx @@ -0,0 +1,495 @@ +import { useState, useCallback, useEffect } from "react"; +import { BlockWrapper } from "./block-wrapper"; +import { AddBlockButton } from "./add-block-button"; +import { BlockSettingsPanel } from "./block-settings-panel"; +import { EditorLayout } from "./layout/editor-layout"; +import type { EmailBlock, Email, EmailStyle } from "../types/email-editor"; +import type { BlockRegistry } from "../types/block-registry"; +import { defaultEmail } from "../lib/default-email"; +import "../index.css"; + +// Simple ID generator +function generateId() { + return Math.random().toString(36).substr(2, 9); +} + +// Helper function to deep merge objects +function deepMerge(target: any, source: any) { + const output = { ...target }; + + if (isObject(target) && isObject(source)) { + Object.keys(source).forEach((key) => { + if (isObject(source[key])) { + if (!(key in target)) { + Object.assign(output, { [key]: source[key] }); + } else { + output[key] = deepMerge(target[key], source[key]); + } + } else { + Object.assign(output, { [key]: source[key] }); + } + }); + } + + return output; +} + +function isObject(item: any) { + return item && typeof item === "object" && !Array.isArray(item); +} + +function getEmailWithBlockIds(email: Email): Email { + return { + style: email.style, + meta: email.meta, + content: email.content.map((block) => ({ + ...block, + id: generateId(), + settings: block.settings || {}, + })), + }; +} + +function stripBlockIds(email: Email): Email { + return { + ...email, + content: email.content.map((block) => ({ ...block, id: undefined })), + }; +} + +function getDefaultSettingsForBlockType( + blockType: string, +): Record { + const commonSettings = {}; + + switch (blockType) { + case "text": + return { + ...commonSettings, + content: "New text block", + }; + case "separator": + return { + ...commonSettings, + color: "#e2e8f0", + thickness: "1px", + style: "solid", + marginY: "16px", + }; + case "image": + return { + ...commonSettings, + src: "", + alt: "Image", + alignment: "left", + width: "auto", + height: "auto", + maxWidth: "100%", + borderRadius: "0px", + padding: "16px", + }; + case "link": + return { + ...commonSettings, + text: "Link Text", + url: "#", + alignment: "left", + textColor: "#0284c7", + fontSize: "16px", + textDecoration: "underline", + isButton: false, + }; + default: + return {} as Record; + } +} + +interface EmailEditorProps { + initialEmail?: Email; + onChange?: (email: Email) => void; + blockRegistry: BlockRegistry; +} + +export function EmailEditor({ + initialEmail, + onChange, + blockRegistry, +}: EmailEditorProps) { + const [email, setEmail] = useState( + getEmailWithBlockIds(initialEmail || defaultEmail), + ); + const [movingBlockId, setMovingBlockId] = useState(null); + const [selectedBlockId, setSelectedBlockId] = useState(null); + const [showSettings] = useState(true); + + // Update email when initialEmail prop changes + useEffect(() => { + if (initialEmail) { + setEmail(getEmailWithBlockIds(initialEmail)); + } + }, [initialEmail]); + + const updateEmail = useCallback( + (newEmail: Email) => { + setEmail(newEmail); + if (onChange) { + onChange(stripBlockIds(newEmail)); + } + }, + [onChange], + ); + + const updateEmailStyle = useCallback( + (styleUpdate: Partial) => { + setEmail((prevEmail) => { + const newEmail = { + ...prevEmail, + style: deepMerge(prevEmail.style, styleUpdate), + }; + + if (onChange) { + onChange(stripBlockIds(newEmail)); + } + + return newEmail; + }); + }, + [onChange], + ); + + const addBlock = useCallback( + (blockType: string, index: number) => { + const newBlock: EmailBlock = { + id: generateId(), + blockType, + settings: getDefaultSettingsForBlockType(blockType), + }; + + setEmail((prevEmail) => { + const newEmail = { + ...prevEmail, + content: [ + ...prevEmail.content.slice(0, index), + newBlock, + ...prevEmail.content.slice(index), + ], + }; + + if (onChange) { + onChange(stripBlockIds(newEmail)); + } + + return newEmail; + }); + + setSelectedBlockId(newBlock.id!); + }, + [onChange], + ); + + const updateBlock = useCallback( + (id: string, content: Partial) => { + const newEmail = { + ...email, + content: email.content.map((block) => + block.id === id ? { ...block, ...content } : block, + ), + }; + + setEmail(newEmail); + + if (onChange) { + onChange(stripBlockIds(newEmail)); + } + }, + [email, onChange], + ); + + // const updateBlockStyle = useCallback( + // (id: string, style: Partial