diff --git a/.changeset/config.json b/.changeset/config.json index 9ab41124f..52fe169dd 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -9,7 +9,6 @@ "updateInternalDependencies": "patch", "ignore": [ "@courselit/web", - "@courselit/state-management", "@courselit/utils", "@courselit/queue", "@courselit/icons", diff --git a/.cursor/rules/basics.mdc b/.cursor/rules/basics.mdc new file mode 100644 index 000000000..882945ab0 --- /dev/null +++ b/.cursor/rules/basics.mdc @@ -0,0 +1,11 @@ +--- +alwaysApply: true +--- + +- Use `pnpm` as package manager. +- The project is structured as a monorepo i.e. a pnpm workspace. The apps are in `apps` folder and re-usable packages are in `packages`. +- Command for running script in a workspace: `pnpm --filter `. +- Command for running tests: `pnpm test`. +- The project uses shadcn for building UI so stick to its conventions and design. +- In `apps/web` workspace, create a string first in `apps/web/ui-config/strings.ts` and then import it in the `.tsx` files, instead of using inline strings. +- When working with forms, always use refs to keep the current state of the form's data and use it to enable/disable the form submit button. \ No newline at end of file diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 40b878db5..000000000 --- a/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -node_modules/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 13974cbeb..21f9023a2 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,6 @@ coverage # Text editors configurations .vscode .rgignore -.cursor # Env file .env*.local @@ -39,4 +38,7 @@ report*.json # Dev tools files .eslintcache -.npmrc \ No newline at end of file +.npmrc + +# Jest files +globalConfig.json \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit index 25d223573..d0a778429 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1 @@ -#!/usr/bin/env sh -. "$(dirname "$0")/_/husky.sh" - npx lint-staged \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..61679526c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,20 @@ +## Development Tips + +- Use `pnpm` as package manager. +- The project is structured as a monorepo i.e. a pnpm workspace. The apps are in `apps` folder and re-usable packages are in `packages`. +- Command for running script in a workspace: `pnpm --filter `. +- Command for running tests: `pnpm test`. +- The project uses shadcn for building UI so stick to its conventions and design. +- In `apps/web` workspace, create a string first in `apps/web/config/strings.ts` and then import it in the `.tsx` files, instead of using inline strings. +- When working with forms, always use refs to keep the current state of the form's data and use it to enable/disable the form submit button. +- Check the name field inside each package's package.json to confirm the right name—skip the top-level one. + +## Testing instructions + +- Always add or update test when introducing changes to `apps/web/graphql` folder, even if nobody asked. +- Run `pnpm test` to run the tests. +- Fix any test or type errors until the whole suite is green. + +## PR instructions + +- Always run `pnpm lint` and `pnpm prettier` before committing. diff --git a/apps/docs/public/assets/communities/delete-community.png b/apps/docs/public/assets/communities/delete-community.png new file mode 100644 index 000000000..c4ee06826 Binary files /dev/null and b/apps/docs/public/assets/communities/delete-community.png differ diff --git a/apps/docs/public/assets/products/accomplishment-page.png b/apps/docs/public/assets/products/accomplishment-page.png new file mode 100644 index 000000000..def0fbf78 Binary files /dev/null and b/apps/docs/public/assets/products/accomplishment-page.png differ diff --git a/apps/docs/public/assets/products/certificate-badge-product-card.png b/apps/docs/public/assets/products/certificate-badge-product-card.png new file mode 100644 index 000000000..1a9911d47 Binary files /dev/null and b/apps/docs/public/assets/products/certificate-badge-product-card.png differ diff --git a/apps/docs/public/assets/products/certificate-template-customization-expand.png b/apps/docs/public/assets/products/certificate-template-customization-expand.png new file mode 100644 index 000000000..fdc989ee9 Binary files /dev/null and b/apps/docs/public/assets/products/certificate-template-customization-expand.png differ diff --git a/apps/docs/public/assets/products/courselit-certificate-example.jpg b/apps/docs/public/assets/products/courselit-certificate-example.jpg new file mode 100644 index 000000000..c2b365304 Binary files /dev/null and b/apps/docs/public/assets/products/courselit-certificate-example.jpg differ diff --git a/apps/docs/public/assets/products/customize-certificate-texts.png b/apps/docs/public/assets/products/customize-certificate-texts.png new file mode 100644 index 000000000..592b4f775 Binary files /dev/null and b/apps/docs/public/assets/products/customize-certificate-texts.png differ diff --git a/apps/docs/public/assets/products/issue-certificate-toggle.png b/apps/docs/public/assets/products/issue-certificate-toggle.png new file mode 100644 index 000000000..19c1b15df Binary files /dev/null and b/apps/docs/public/assets/products/issue-certificate-toggle.png differ diff --git a/apps/docs/public/assets/products/preview-certificate.png b/apps/docs/public/assets/products/preview-certificate.png new file mode 100644 index 000000000..c03487eb4 Binary files /dev/null and b/apps/docs/public/assets/products/preview-certificate.png differ diff --git a/apps/docs/public/assets/products/product-manage-menu.png b/apps/docs/public/assets/products/product-manage-menu.png new file mode 100644 index 000000000..c088909da Binary files /dev/null and b/apps/docs/public/assets/products/product-manage-menu.png differ diff --git a/apps/docs/public/assets/products/view-certificate-button.png b/apps/docs/public/assets/products/view-certificate-button.png new file mode 100644 index 000000000..2926f5815 Binary files /dev/null and b/apps/docs/public/assets/products/view-certificate-button.png differ diff --git a/apps/docs/public/assets/schools/self-host.svg b/apps/docs/public/assets/schools/self-host.svg new file mode 100644 index 000000000..5264426ad --- /dev/null +++ b/apps/docs/public/assets/schools/self-host.svg @@ -0,0 +1,3 @@ +CourseLitMediaLitYour VPS serverBrowserSchool dataMedia filesschool.commedia.school.comCourseLitMediaLitYour VPS serverBrowserSchool dataMedia filesschool.commedialit.cloudMediaLit.cloud serverOption A - You Control EverythingOption B - You Control School, We control Media Handling \ No newline at end of file diff --git a/apps/docs/public/assets/users/delete-user.png b/apps/docs/public/assets/users/delete-user.png new file mode 100644 index 000000000..2fbc09f44 Binary files /dev/null and b/apps/docs/public/assets/users/delete-user.png differ diff --git a/apps/docs/src/config.ts b/apps/docs/src/config.ts index 93a61caa3..5406a66b4 100644 --- a/apps/docs/src/config.ts +++ b/apps/docs/src/config.ts @@ -47,6 +47,7 @@ export const SIDEBAR: Sidebar = { en: { "Getting started": [ { text: "What is CourseLit", link: "en/introduction" }, + { text: "Our vision", link: "en/vision" }, { text: "Features", link: "en/features" }, ], "Online courses": [ @@ -58,6 +59,7 @@ export const SIDEBAR: Sidebar = { { text: "Add content", link: "en/courses/add-content" }, { text: "Manage sections", link: "en/products/section" }, { text: "Invite customers", link: "en/products/invite-customers" }, + { text: "Certificates", link: "en/products/certificates" }, ], "Digital downloads": [ { text: "Introduction", link: "en/downloads/introduction" }, @@ -77,6 +79,7 @@ export const SIDEBAR: Sidebar = { text: "Unlock additional products", link: "en/communities/grant-access-to-additional-products", }, + { text: "Delete a community", link: "en/communities/delete" }, ], "Email marketing and automation": [ { text: "Introduction", link: "en/email-marketing/introduction" }, @@ -120,6 +123,7 @@ export const SIDEBAR: Sidebar = { { text: "User permissions", link: "en/users/permissions" }, { text: "Filter users", link: "en/users/filters" }, { text: "Segment users", link: "en/users/segments" }, + { text: "Delete a user", link: "en/users/delete" }, ], Developers: [ { text: "Introduction", link: "en/developers/introduction" }, diff --git a/apps/docs/src/pages/en/communities/delete.md b/apps/docs/src/pages/en/communities/delete.md new file mode 100644 index 000000000..7de751c94 --- /dev/null +++ b/apps/docs/src/pages/en/communities/delete.md @@ -0,0 +1,201 @@ +--- +title: Delete a Community +description: Guide to safely deleting communities and all associated data +layout: ../../../layouts/MainLayout.astro +--- + +Deleting a community is a permanent operation that removes the community and all its associated data from your school. This includes all posts, comments, members, and payment plans. + +> **Important**: Community deletion is permanent and cannot be undone. All community content, memberships, and payment plans will be permanently removed. + +## How Community Deletion Works + +When you delete a community, CourseLit performs a comprehensive cleanup: + +1. **Content Deletion**: Removes all posts, comments, and reports +2. **Membership Cleanup**: Cancels all memberships and payment plans +3. **Page Removal**: Deletes the community's public page +4. **Media Cleanup**: Removes all associated media files +5. **Community Document**: Deletes the community itself + +## Prerequisites + +To delete a community, you must have the `Manage Community` permission. + +> **Note**: Even community moderators cannot delete a community. Only users with the `Manage Community` permission (typically site admins) can perform this operation. + +## Deleting a Community + +1. Navigate to the **Communities** area from your admin dashboard +2. Select the community you want to delete +3. Click on **Manage** to open settings +4. Scroll down to the Danger zone and click on **Delete Community** button + + ![Delete community](/assets/communities/delete-community.png) + +5. Confirm the deletion when prompted + +## What Gets Deleted + +### Community Content + +All content within the community is permanently removed: + +- **Posts**: All posts created in the community +- **Comments**: All comments on posts, including nested replies +- **Reports**: All content reports filed by members +- **Media**: All images, videos, and files uploaded to posts (when media uploads are enabled) + +### Memberships & Subscriptions + +All membership-related data is removed: + +- **Community Memberships**: All member records for the community +- **Payment Subscriptions**: All active subscriptions are automatically cancelled +- **Payment Plans**: All payment plans associated with the community +- **Included Product Memberships**: If the community's payment plans included access to courses, those memberships are also removed +- **Post Subscriptions**: All user subscriptions to community posts + +### Community Infrastructure + +The community's infrastructure is removed: + +- **Community Page**: The public-facing community page +- **Community Settings**: All configuration and settings +- **Categories**: All community categories +- **Featured Images**: Community banner and featured images + +### Related Data + +Additional data associated with the community: + +- **Activities**: Activity logs related to payment plan enrollments +- **Notifications**: Notifications related to the community (for members) + +## What Happens to Members + +When a community is deleted: + +1. **Active Subscriptions**: All payment subscriptions are automatically cancelled through your payment provider (Stripe, PayPal, etc.) +2. **Membership Records**: All membership records are permanently deleted +3. **Access Revoked**: Members immediately lose access to the community +4. **Included Products**: If members had access to courses through the community's payment plan, that access is also revoked + +## Payment Plan Considerations + +### Subscription Cancellations + +- All active subscriptions are cancelled automatically +- Payment providers (Stripe, PayPal, etc.) are notified +- No further charges will occur +- Members will not receive refunds automatically + +### Included Products + +If your community's payment plans [included access to courses](/en/communities/grant-access-to-additional-products) or other products: + +- All memberships to those products (activated through the community plan) are removed +- Activity logs for those memberships are deleted +- Direct purchases of those products (not through the community) are not affected + +## Media Cleanup + +The deletion process handles media files appropriately: + +- **Community Images**: Featured images and banners are deleted +- **Post Media**: When media uploads are enabled, all media from posts is deleted +- **User Avatars**: Not affected (user avatars are tied to user accounts, not communities) + +> **Note**: Currently, media uploads in community posts are not enabled. When this feature is activated, the deletion process will handle post media cleanup automatically. + +## Safety Measures + +CourseLit implements safety measures to ensure proper deletion: + +1. **Permission Check**: Only users with `Manage Community` permission can delete +2. **Confirmation Required**: Deletion requires explicit confirmation +3. **Atomic Operation**: The entire deletion succeeds or fails as a unit +4. **Subscription Cancellation**: Automatic cancellation prevents future charges + +## Before Deleting a Community + +Consider these steps before deleting: + +1. **Notify Members**: Inform community members about the upcoming deletion +2. **Export Data**: If you need to preserve any content, export it manually. Only works for self-hosted installations. +3. **Handle Refunds**: Process any necessary refunds through your payment provider +4. **Alternative Actions**: Consider making the community private instead of deleting it + +## After Deletion + +After a community is deleted: + +- The community page returns a 404 error +- Members cannot access the community anymore +- All content is permanently lost +- Payment subscriptions are cancelled +- The community name becomes available for reuse + +## Handling Refunds + +Community deletion does not automatically issue refunds. To handle refunds: + +1. **Before Deletion**: Note all active subscriptions and their subscription IDs +2. **Access Payment Provider**: Log into your Stripe, PayPal, or other payment provider dashboard +3. **Process Refunds**: Manually issue refunds as appropriate +4. **Delete Community**: Once refunds are processed, proceed with deletion + +## Alternative to Deletion + +If you want to preserve content but stop new members from joining: + +1. **Disable the Community**: Toggle the community to "disabled" in settings +2. **Remove Payment Plans**: Archive all payment plans + +This approach preserves content while preventing new access. + +## Troubleshooting + +### Cannot Delete Community + +If you encounter errors: + +- **"Action not allowed"**: You don't have the `Manage Community` permission +- **"Item not found"**: The community may have already been deleted or doesn't exist +- **"Community not found"**: You may not have access to this community + +### Subscription Cancellation Issues + +If subscriptions fail to cancel: + +1. Manually cancel subscriptions in your payment provider's dashboard +2. Contact support if issues persist + +### Partial Deletion + +If deletion fails partway through: + +- The operation is designed to be atomic, but in rare cases, partial deletion may occur +- Contact support with error details +- Manual cleanup may be required + +## Best Practices + +1. **Communicate Early**: Give members advance notice before deletion +2. **Export Important Content**: Save any valuable discussions or content +3. **Process Refunds First**: Handle refunds before deleting to maintain records +4. **Document the Decision**: Keep records of why and when the community was deleted +5. **Consider Alternatives**: Evaluate if disabling is sufficient instead of deletion + +## GDPR and Data Protection + +Community deletion helps with data protection compliance: + +- All member data within the community is removed +- Personal information in posts and comments is deleted +- Membership records are permanently erased +- The operation can be part of a broader data cleanup strategy + +## Stuck somewhere? + +We are always here for you. Come chat with us in our Discord channel or send a tweet at @CourseLit. diff --git a/apps/docs/src/pages/en/features.md b/apps/docs/src/pages/en/features.md index 8a834b00f..350f39fc7 100644 --- a/apps/docs/src/pages/en/features.md +++ b/apps/docs/src/pages/en/features.md @@ -53,6 +53,14 @@ Send broadcasts and email sequences to your audience.

+
+ +
Payment Gateway Integration
+

+Integrate your own payment gateway, such as Stripe, and keep 100% of your earnings. +

+
+
### Other noteworthy features @@ -65,10 +73,6 @@ Effortlessly track your students' progress, control platform access, and more. Invite your entire team and control their access with our intuitive permission editor. -##### Payment integration - -Start getting paid directly with ease—integrate your own [Stripe](https://stripe.com) or [Razorpay](https://razorpay.com) account in just a few clicks! - ##### Third Party Integrations Integrate analytics, chatbots etc., by injecting third party code snippets. @@ -83,4 +87,4 @@ Read and modify the source code to build your own variations. ## 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 at @CourseLit. diff --git a/apps/docs/src/pages/en/introduction.md b/apps/docs/src/pages/en/introduction.md index 4c3642933..08aa2e4d2 100644 --- a/apps/docs/src/pages/en/introduction.md +++ b/apps/docs/src/pages/en/introduction.md @@ -6,27 +6,39 @@ layout: ../../layouts/MainLayout.astro ## What is CourseLit? -CourseLit is a **Learning Management System** for modern day creators who want to sell courses and digital downloads from their own websites. +CourseLit is an **all-in-one suite** for building, selling, and marketing your digital products, such as courses, memberships and digital downloads. + +CourseLit was born out of frustration with the exorbitant costs and limitations of traditional course builders and LMS platforms like Teachable, Thinkific, and Podia. That’s why **we offer all essential features—course creation, memberships, website, analytics, email marketing and payment gateways—in a single Pro Plan priced under $20/month**, empowering solo creators like marketing coaches and small businesses like digital agencies with 1-50 employees to scale without financial strain or costly upgrades. + +Plus, CourseLit is fully open-source and self-hostable, letting you host your educational empire at zero license cost—only server expenses of $5–$20/month on AWS or DigitalOcean. Our up-to-date documentation and vibrant community (via Discord and GitHub) make this process a breeze, ensuring you own your platform and destiny, free from vendor lock-in. + +## Why did we create it? + +We are building the **"WordPress of course and membership platforms"**. + +Discover [our vision](/en/vision) for creating CourseLit and learn why we set out to build a unique platform, despite the many options already available. ## Features -CourseLit has got everything you'd ever need to successfully run your online teaching business. +CourseLit has everything you'd ever need to successfully run your online teaching business, including **course creation, memberships, website, analytics, email marketing**, and **payment gateways**—all bundled to power your growth. See all the features [here](/en/features). ## Try CourseLit -Visit courselit.app to use the cloud hosted version. Sign up for a free account to get a **14 days trial period** to experience the platform without any restrictions. **No credit card required**. +Visit courselit.app to use the cloud-hosted version. Sign up for a free account to get a **14-day trial period** to experience the platform without any restrictions. **No credit card required**. ## Self Hosting -CourseLit is an open-source LMS and can be hosted on a server where you control everything. If data ownership is at the center of your business, self-hosting CourseLit is a way to go. +CourseLit is an open-source LMS and can be hosted on a server where you control everything. If data ownership is at the center of your business, self-hosting CourseLit is the way to go. -Follow [our guide](/en/self-hosting/introduction) for full, step by step instructions for hosting CourseLit on your server. +Follow [our guide](/en/self-hosting/introduction) for full, step-by-step instructions for hosting CourseLit on your server. ## Join Our Community -Join our [Discord](https://discord.com/invite/GR4bQsN) channel to get help, to request for features or to just hang out with an awesome community of creators. +Join our [Discord](https://discord.com/invite/GR4bQsN) channel to get help, request features, or just hang out with an awesome community of creators. + +You can also stay updated by following us on [X](https://x.com/courselit). ## Learn More diff --git a/apps/docs/src/pages/en/products/certificates.md b/apps/docs/src/pages/en/products/certificates.md new file mode 100644 index 000000000..277f5bd40 --- /dev/null +++ b/apps/docs/src/pages/en/products/certificates.md @@ -0,0 +1,88 @@ +--- +title: Certificates +description: Issue certificate on course completion +layout: ../../../layouts/MainLayout.astro +--- + +Certificates add significant value to your courses by providing your customers with verifiable credentials they can showcase. CourseLit makes it effortless to issue certificates automatically upon course completion. + +Here are the key features of CourseLit certificates: + +- A dedicated page for each certificate issued, allowing customers to share a verifiable link on their profiles +- Customizable text and images to make certificates reflect your brand + +**Note**: Certificates are only available for `Course` products. + +## Overview + +Customers will receive a certificate after completing all lessons in a course. + +The certificate will be hosted on a dedicated page that can be used to verify the customer's learning achievement. + +Here is an example certificate: + +![CourseLit Example Certificate](/assets/products/courselit-certificate-example.jpg) + +## Enable certificates for a course + +1. Go to the manage section of your `Course` product. + + ![Product manage menu](/assets/products/product-manage-menu.png) + +2. Scroll down to the `Certificates` section and toggle on the `Issue certificate` switch. + + ![Issue certificate switch](/assets/products/issue-certificate-toggle.png) + + That's it! Your customers will now receive a certificate upon course completion. + + > Previous customers will not receive certificates retroactively, as the certificate generation process is automated. + +## Customize the certificate + +1. Click on the `Chevron` icon on the far right of the `Certificate Template` subsection under the `Certificates` section to expand it. + + ![Certificate template customization expand button](/assets/products/certificate-template-customization-expand.png) + +2. Here are the elements you can customize in a certificate: + + - Certificate Title + - Certificate Subtitle + - Certificate Description + - Signature Name + - Signature Designation + - Signature Image + - Logo (defaults to school's logo if not provided) + +3. Modify the text configuration to your preference and click `Save`. + + ![Customize certificate texts](/assets/products/customize-certificate-texts.png) + +4. `Signature Image` and `Logo` properties are automatically saved. + +## Preview the certificate + +Click on the `Preview` link located to the left of the `Issue certificates` switch. + +You will be taken to a new tab where you can see an actual certificate in PDF format with your changes applied. + +![Preview certificate](/assets/products/preview-certificate.png) + +## Customer's experience + +Upon completing a course, customers will be taken back to the `My content` lobby. On the product card, they will see a badge indicating that a certificate is available. + +![Certificate badge on product card](/assets/products/certificate-badge-product-card.png) + +On the course's introduction page, customers will see a `View certificate` button. + +![View certificate button on product's intro in course viewer](/assets/products/view-certificate-button.png) + +When they click the `View certificate` button, customers are taken to the `Accomplishment` page, where they can view all certification details. + +![Accomplishment Page](/assets/products/accomplishment-page.png) + +Customers can also print their certificate by clicking the `Print` button. + +## Stuck somewhere? + +We are always here for you. Come chat with us in our Discord channel or send a tweet at @CourseLit. diff --git a/apps/docs/src/pages/en/self-hosting/introduction.md b/apps/docs/src/pages/en/self-hosting/introduction.md index 5613f3bd4..8d98e375a 100644 --- a/apps/docs/src/pages/en/self-hosting/introduction.md +++ b/apps/docs/src/pages/en/self-hosting/introduction.md @@ -15,6 +15,10 @@ You should self host CourseLit, if you: - want complete control of your data - want to host it behind a firewall for internal use +## How to self host? + +![Self hosting options](/assets/schools/self-host.svg) + ### Self host CourseLit See [the self hosting guide](/en/self-hosting/self-host). diff --git a/apps/docs/src/pages/en/users/delete.md b/apps/docs/src/pages/en/users/delete.md new file mode 100644 index 000000000..440cfe944 --- /dev/null +++ b/apps/docs/src/pages/en/users/delete.md @@ -0,0 +1,169 @@ +--- +title: Delete a User +description: Guide to safely deleting users with GDPR compliance +layout: ../../../layouts/MainLayout.astro +--- + +Deleting a user is a critical operation that removes all personal data associated with that user from your school. This feature is designed to comply with GDPR and other data protection regulations. + +> **Important**: User deletion is permanent and cannot be undone. All personal data will be removed, and business entities will be transferred to the admin performing the deletion. + +## How User Deletion Works + +When you delete a user, CourseLit performs two main operations: + +1. **Business Entity Migration**: Transfers ownership of courses, communities, email templates, and other business-critical resources to the admin performing the deletion +2. **Personal Data Cleanup**: Permanently removes all personal data associated with the user + +## Prerequisites + +To delete a user, you must have the `Manage Users` permission. Additionally: + +- You cannot delete yourself +- You cannot delete the last user with critical permissions (like `Manage Site`, `Manage Users`, etc.) +- The system ensures at least one admin remains with each critical permission + +## Deleting a User + +1. Navigate to the **Users** area from the dashboard +2. Click on the user you want to delete to open their details +3. Scroll down to the Danger zone and click on **Delete user** button + + ![Delete user](/assets/users/delete-user.png) + +4. Confirm the deletion when prompted + +## What Gets Migrated + +The following business entities are transferred to the admin performing the deletion: + +### Courses & Content + +- **Course Ownership**: All courses created by the user +- **Lesson Ownership**: All lessons created by the user +- **Page Ownership**: All pages created by the user (course pages, blog pages, etc.) + +### Communities + +- **Community Ownership**: All communities created by the user +- **Community Posts**: All posts created by the user in any community +- **Community Comments**: All comments made by the user + +### Email Marketing + +- **Email Templates**: All email templates created by the user +- **Email Sequences**: All email sequences (campaigns) created by the user +- **Broadcasts**: All email broadcasts created by the user + +### Other Business Entities + +- **Payment Plans**: All payment plans created by the user +- **User Themes**: All custom themes created by the user +- **User Segments**: All user segments created by the user + +## What Gets Deleted + +The following personal data is permanently removed: + +### User Account & Profile + +- User account and profile information +- User avatar and media files +- Authentication tokens and sessions + +### Activity & Engagement + +- Course enrollments and memberships +- Lesson progress and evaluations +- Download links generated for the user +- Activity logs and analytics data +- Notifications sent to the user + +### Community Participation + +- Community membership records +- Community post subscriptions +- Community reports filed by the user + +### Email & Communications + +- Email delivery records +- Email event logs (opens, clicks, etc.) +- Ongoing email sequences for the user +- Mail request status records + +### Financial Records + +- Invoices associated with the user +- Payment subscriptions (cancelled automatically) + +### Certificates + +- Certificates issued to the user + +## GDPR Compliance + +This deletion process is designed to comply with GDPR Article 17 (Right to Erasure). When a user is deleted: + +- All personal data is permanently removed +- Business entities are preserved to maintain system integrity +- The operation is logged for audit purposes +- Payment subscriptions are automatically cancelled + +## Safety Measures + +CourseLit implements several safety measures to prevent accidental data loss: + +1. **Permission Validation**: Ensures at least one user retains each critical permission +2. **Self-Deletion Prevention**: You cannot delete your own account +3. **Confirmation Required**: Deletion requires explicit confirmation +4. **Atomic Operation**: The entire deletion process succeeds or fails as a unit + +## After Deletion + +After a user is deleted: + +- All their business entities (courses, communities, etc.) continue to function normally under the new owner +- Students enrolled in their courses can continue learning +- Community members can continue participating +- Email sequences continue running for other users +- The deleted user cannot log in anymore + +## Handling Subscription Cancellations + +If the deleted user had active payment subscriptions: + +- All subscriptions are automatically cancelled +- The payment provider (Stripe, PayPal, etc.) is notified +- No further charges will occur +- Refunds must be handled manually through your payment provider if needed + +## Best Practices + +1. **Review Before Deletion**: Check what content and entities the user owns before deleting +2. **Notify Stakeholders**: If the user created important courses or communities, inform relevant team members +3. **Export Data First**: If you need to retain any information for records, export it before deletion +4. **Handle Refunds**: Process any necessary refunds through your payment provider before deletion +5. **Document the Action**: Keep a record of why and when the user was deleted for compliance purposes + +## Troubleshooting + +### Cannot Delete User + +If you encounter an error when trying to delete a user: + +- **"Cannot delete last user with permission X"**: This user is the last one with a critical permission. Assign this permission to another user first. +- **"Action not allowed"**: You don't have the `Manage Users` permission. +- **"User not found"**: The user may have already been deleted or doesn't exist. + +### Subscription Cancellation Failed + +If a subscription fails to cancel: + +1. Note the subscription ID from the error message +2. Manually cancel the subscription in your payment provider's dashboard +3. Try the deletion again + +## Stuck somewhere? + +We are always here for you. Come chat with us in our Discord channel or send a tweet at @CourseLit. diff --git a/apps/docs/src/pages/en/vision.md b/apps/docs/src/pages/en/vision.md new file mode 100644 index 000000000..21a91085b --- /dev/null +++ b/apps/docs/src/pages/en/vision.md @@ -0,0 +1,94 @@ +--- +title: Our vision +description: Our vision and philosophy +layout: ../../layouts/MainLayout.astro +--- + +Our vision for CourseLit is to establish a **parallel, modern ecosystem of open-source tools** that competes head-to-head with industry leaders like Teachable, Thinkific, Podia, and Learnworlds, while remaining **accessible and affordable for everyone**. + +We are essentially building the **"WordPress of course and membership platforms."** + +--- + +## The Problem We Solve + +The current course platform landscape presents significant barriers for creators: + +1. **Exorbitant Cost Barrier** + + Incumbents charge high monthly fees (often $50–$200/month), burdening solo creators like marketing coaches or small businesses with 1–50 employees, especially those bootstrapping or operating in emerging markets. + +2. **Feature Lock-Out and Upselling** + + Essential features like certificates, subscriptions, or the ability to add their own payment gateways are locked behind expensive premium tiers, forcing creators and teams to upgrade constantly. + +3. **Vendor Lock-In** + + Proprietary platforms trap creators in ecosystems where data, branding, and functionality are controlled, risking loss if the platform raises prices or shuts down. + +--- + +## Our Foundational Philosophy + +CourseLit is built on two core commitments: **Affordability and Freedom**. + +### Commitment to Affordability (The Pricing Promise) + +We believe core functionality should never be a premium add-on. + +1. **Single Affordable Pro Plan** + + All essential features (e.g., course creation, memberships, analytics, payment gateways) are consolidated into a single **Pro** Plan priced **under $30/month**, allowing solo creators and small businesses to launch and scale without financial strain. + +2. **Tiered Pricing Discipline** + + Future enterprise features (e.g., SSO, advanced integrations, webhooks, API access) will be offered in a higher tier, but all essential tools will remain in the Pro Plan, ensuring accessibility for small creators and teams. + +### Commitment to Freedom (The Open-Source Mandate) + +Creators should own their platform and destiny. + +1. **No Vendor Lock-In** + + The entire CourseLit stack is open-source, hosted on [GitHub](https://github.com/codelitdev/courselit) for longevity. If [CodeLit](https://codelit.dev), the company behind CourseLit, ever ceases operations, the tools remain available for anyone to use, extend, or fork, ensuring creators’ ecosystems are secure. + +2. **The Free Path** + + Technical users can [self-host](/en/self-hosting/introduction) the entire stack at zero license cost for commercial use. We rely solely on other open-source projects and platforms like MongoDB that offer generous free tiers. The only cost is server hosting (e.g., $5–$20/month on AWS or DigitalOcean). The platform is fully **white-labeled** for branding freedom. + +3. **Documentation and Community** + + We provide up-to-date documentation and maintain an active community (e.g., Discord, GitHub Discussions) to make self-hosting accessible, even for users with moderate technical skills (e.g., familiarity with basic server setup). + +4. **Community-Driven Evolution** + + Technical users, such as developers building custom platforms for agencies, can contribute code (e.g., gamification features, Zapier integrations), documentation, or feature requests via GitHub. Solo creators and small businesses benefit from these enhancements without needing to code, ensuring the platform evolves based on real user needs, not corporate priorities. + +--- + +## Service and Access Model + +CourseLit offers two ways to access the platform, tailored to creators’ needs: + +| Access Model | Target User | Key Principle | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Managed SaaS (`courselit.app`)** | **Solo Creators** (e.g., marketing coaches, consultants) and **Small Businesses** who value convenience and support | **High-Value, Hassle-Free**: We handle hosting, updates, and maintenance via the affordable Pro Plan under $30/month, delivering a plug-and-play experience for creators scaling to 10,000+ students. Community-driven features enhance the platform without requiring technical skills | +| **Self-Hosted (Open Source)** | **Technical Users** (e.g., developers, edtech startups) seeking control or cost-free evaluation | **Zero-Cost Access**: Users download the stack from GitHub and host it themselves (e.g., on AWS, DigitalOcean). All features, including future enterprise tools, are available. Contributors shape the platform's future via code or feedback | + +--- + +## Conclusion + +CourseLit is more than a platform; it’s a **mission to empower online educators**—from solo creators to small businesses—with a modern, feature-rich, and ethical alternative to costly course platforms. We aim to be the obvious choice for: + +1. **Serious Creators**, including solo professionals (e.g., marketing coaches, consultants) and small businesses (e.g., agencies, edtech startups with 1–50 employees), who seek a cost-effective, feature-rich platform to scale their courses and memberships without technical barriers. + +2. **Technical Users**, like developers or edtech startups, who want a customizable, open-source alternative to proprietary platforms, with opportunities to shape its evolution. + +By combining **affordability**, **open-source freedom**, and **community-driven innovation**, we’re building a platform that grows with creators’ needs, much like WordPress transformed web publishing. CourseLit is the **obvious choice** for anyone seeking to build and scale their online school or training program with confidence and control. + +## Join Our Community + +Join our [Discord](https://discord.com/invite/GR4bQsN) channel to get help, request features, or just hang out with an awesome community of creators. + +You can also stay updated by following us on [X](https://x.com/courselit). diff --git a/apps/docs/src/pages/en/website/blocks.md b/apps/docs/src/pages/en/website/blocks.md index a937bd2c6..9f7bed0b9 100644 --- a/apps/docs/src/pages/en/website/blocks.md +++ b/apps/docs/src/pages/en/website/blocks.md @@ -35,8 +35,8 @@ You will also see the newly added link on the header itself. 3. Click on the pencil icon against the newly added link to edit it as shown above. 4. Change the label (displayed as text on the header block) and the URL (where the user should be taken upon clicking the label on the header) and click `Done` to save. -![Header edit link](/assets/pages/header-edit-link.png) - + ![Header edit link](/assets/pages/header-edit-link.png) + ### [Rich Text](#rich-text) @@ -60,8 +60,8 @@ You can also use the floating controls to do the same as shown below. > Double-clicking the text to select won't work due to a bug. We are working on it. 2. Click on the floating `link` button to reveal a popup text input. 3. In the popup text input, enter the URL as shown below. -![Create a hyperlink in rich text block](/assets/pages/rich-text-create-hyperlink.gif) - + ![Create a hyperlink in rich text block](/assets/pages/rich-text-create-hyperlink.gif) + ### [Hero](#hero) @@ -85,9 +85,9 @@ Following is how it looks on a page. 3. In the button text field, add the text that will be visible on the button. 4. In the button action, enter the URL the user should be taken to upon clicking. -a. If the URL is from your own school, use its relative form, i.e., `/courses`. -b. If the URL is from some external website, use the absolute (complete) URL, i.e., `https://website.com/courses`. - + a. If the URL is from your own school, use its relative form, i.e., `/courses`. + b. If the URL is from some external website, use the absolute (complete) URL, i.e., `https://website.com/courses`. + ### [Grid](#grid) @@ -130,9 +130,9 @@ A grid block comes in handy when you want to show some sort of list, for example 3. In the button text field, add the text that will be visible on the button. 4. In the button action, enter the URL the user should be taken to upon clicking. -a. If the URL is from your own school, use its relative form, i.e., `/courses`. -b. If the URL is from some external website, use the absolute (complete) URL, i.e., `https://website.com/courses`. - + a. If the URL is from your own school, use its relative form, i.e., `/courses`. + b. If the URL is from some external website, use the absolute (complete) URL, i.e., `https://website.com/courses`. + ### [Featured](#featured) diff --git a/apps/queue/package.json b/apps/queue/package.json index 803edd747..e2b6ca903 100644 --- a/apps/queue/package.json +++ b/apps/queue/package.json @@ -33,7 +33,7 @@ "@types/nodemailer": "^6.4.8", "tsconfig": "workspace:^", "tsup": "^7.2.0", - "typescript": "^5.0.4", - "typescript-eslint": "^7.5.0" + "typescript": "^5.9.3", + "typescript-eslint": "^8.46.4" } } diff --git a/apps/queue/src/domain/model/domain.ts b/apps/queue/src/domain/model/domain.ts index c0bbf983e..9c9fb341a 100644 --- a/apps/queue/src/domain/model/domain.ts +++ b/apps/queue/src/domain/model/domain.ts @@ -1,23 +1,28 @@ import { Domain as PublicDomain } from "@courselit/common-models"; -import mongoose from "mongoose"; +import mongoose, { Document, Model } from "mongoose"; import SettingsSchema from "./site-info"; -export interface Domain extends PublicDomain { - _id: mongoose.Types.ObjectId; -} +export type DomainDocument = Document & + PublicDomain & { + _id: mongoose.Types.ObjectId; + incrementEmailCount: () => Promise; + }; -const DomainSchema = new mongoose.Schema( +const DomainSchema = new mongoose.Schema( { name: { type: String, required: true, unique: true }, settings: SettingsSchema, - quota: new mongoose.Schema({ - mail: new mongoose.Schema({ + quota: new mongoose.Schema({ + mail: new mongoose.Schema({ daily: { type: Number, default: 0 }, monthly: { type: Number, default: 0 }, dailyCount: { type: Number, default: 0 }, monthlyCount: { type: Number, default: 0 }, - lastDailyCountUpdate: { type: Date, default: Date.now }, - lastMonthlyCountUpdate: { type: Date, default: Date.now }, + lastDailyCountUpdate: { type: Date, default: () => new Date() }, + lastMonthlyCountUpdate: { + type: Date, + default: () => new Date(), + }, }), }), }, @@ -26,7 +31,9 @@ const DomainSchema = new mongoose.Schema( }, ); -DomainSchema.methods.incrementEmailCount = async function () { +DomainSchema.methods.incrementEmailCount = async function ( + this: DomainDocument, +) { const today = new Date().toISOString().split("T")[0]; const thisMonth = new Date().toISOString().slice(0, 7); const lastDailyUpdate = new Date(this.quota.mail.lastDailyCountUpdate) @@ -40,17 +47,21 @@ DomainSchema.methods.incrementEmailCount = async function () { this.quota.mail.dailyCount++; } else { this.quota.mail.dailyCount = 1; - this.quota.mail.lastDailyCountUpdate = Date.now(); + this.quota.mail.lastDailyCountUpdate = new Date(); } if (thisMonth === lastMonthlyUpdate) { this.quota.mail.monthlyCount++; } else { this.quota.mail.monthlyCount = 1; - this.quota.mail.lastMonthlyCountUpdate = Date.now(); + this.quota.mail.lastMonthlyCountUpdate = new Date(); } return this.save(); }; -export default mongoose.models.Domain || mongoose.model("Domain", DomainSchema); +const DomainModel = + (mongoose.models.Domain as Model) || + mongoose.model("Domain", DomainSchema); + +export default DomainModel; diff --git a/apps/queue/src/domain/model/email-template.ts b/apps/queue/src/domain/model/email-template.ts index 765087920..fc830478f 100644 --- a/apps/queue/src/domain/model/email-template.ts +++ b/apps/queue/src/domain/model/email-template.ts @@ -1,4 +1,4 @@ -import mongoose from "mongoose"; +import mongoose, { Model } from "mongoose"; import { EmailTemplate as PublicEmailTemplate } from "@courselit/common-models"; import { EmailContentSchema } from "@courselit/common-logic"; @@ -23,5 +23,8 @@ EmailTemplateSchema.index( { unique: true }, ); -export default mongoose.models.EmailTemplate || - mongoose.model("EmailTemplate", EmailTemplateSchema); +const EmailTemplateModel = + (mongoose.models.EmailTemplate as Model) || + mongoose.model("EmailTemplate", EmailTemplateSchema); + +export default EmailTemplateModel; diff --git a/apps/queue/src/domain/model/membership.ts b/apps/queue/src/domain/model/membership.ts index 20a06622e..ac3045c2f 100644 --- a/apps/queue/src/domain/model/membership.ts +++ b/apps/queue/src/domain/model/membership.ts @@ -1,5 +1,8 @@ import { MembershipSchema } from "@courselit/common-logic"; -import mongoose from "mongoose"; +import mongoose, { Model } from "mongoose"; -export default mongoose.models.Membership || +const MembershipModel = + (mongoose.models.Membership as Model) || mongoose.model("Membership", MembershipSchema); + +export default MembershipModel; diff --git a/apps/queue/src/domain/model/ongoing-sequence.ts b/apps/queue/src/domain/model/ongoing-sequence.ts index 824d24132..f29f12b6f 100644 --- a/apps/queue/src/domain/model/ongoing-sequence.ts +++ b/apps/queue/src/domain/model/ongoing-sequence.ts @@ -3,7 +3,7 @@ import mongoose, { Schema, Document } from "mongoose"; export type OngoingSequence = OS & Document & { - domain: mongoose.Schema.Types.ObjectId; + domain: mongoose.Types.ObjectId; }; const OngoingSequenceSchema: Schema = new Schema( @@ -22,5 +22,8 @@ const OngoingSequenceSchema: Schema = new Schema( OngoingSequenceSchema.index({ sequenceId: 1, userId: 1 }, { unique: true }); -export default mongoose.models.OngoingSequence || - mongoose.model("OngoingSequence", OngoingSequenceSchema); +const OngoingSequenceModel = + (mongoose.models.OngoingSequence as mongoose.Model) || + mongoose.model("OngoingSequence", OngoingSequenceSchema); + +export default OngoingSequenceModel; diff --git a/apps/queue/src/domain/model/rule.ts b/apps/queue/src/domain/model/rule.ts index 04cb5d7f9..332e04efb 100644 --- a/apps/queue/src/domain/model/rule.ts +++ b/apps/queue/src/domain/model/rule.ts @@ -1,3 +1,7 @@ -import mongoose from "mongoose"; +import mongoose, { Model } from "mongoose"; import { RuleSchema } from "@courselit/common-logic"; -export default mongoose.models.Rule || mongoose.model("Rule", RuleSchema); + +const RuleModel = + (mongoose.models.Rule as Model) || mongoose.model("Rule", RuleSchema); + +export default RuleModel; diff --git a/apps/queue/src/domain/model/sequence.ts b/apps/queue/src/domain/model/sequence.ts index 7e1ddc1df..a8bfd9018 100644 --- a/apps/queue/src/domain/model/sequence.ts +++ b/apps/queue/src/domain/model/sequence.ts @@ -1,5 +1,8 @@ -import mongoose from "mongoose"; +import mongoose, { Model } from "mongoose"; import { SequenceSchema } from "@courselit/common-logic"; -export default mongoose.models.Sequence || +const SequenceModel = + (mongoose.models.Sequence as Model) || mongoose.model("Sequence", SequenceSchema); + +export default SequenceModel; diff --git a/apps/queue/src/domain/model/user.ts b/apps/queue/src/domain/model/user.ts index c82f3403b..e1ba2caf4 100644 --- a/apps/queue/src/domain/model/user.ts +++ b/apps/queue/src/domain/model/user.ts @@ -1,4 +1,7 @@ -import mongoose from "mongoose"; +import mongoose, { Model } from "mongoose"; import { UserSchema } from "@courselit/common-logic"; -export default mongoose.models.User || mongoose.model("User", UserSchema); +const UserModel = + (mongoose.models.User as Model) || mongoose.model("User", UserSchema); + +export default UserModel; diff --git a/apps/queue/src/domain/process-ongoing-sequences.ts b/apps/queue/src/domain/process-ongoing-sequences.ts index edf42f2b6..7136fb8d2 100644 --- a/apps/queue/src/domain/process-ongoing-sequences.ts +++ b/apps/queue/src/domain/process-ongoing-sequences.ts @@ -1,4 +1,4 @@ -import { Domain, Email } from "@courselit/common-models"; +import { Email } from "@courselit/common-models"; import OngoingSequenceModel, { OngoingSequence, } from "./model/ongoing-sequence"; @@ -26,6 +26,7 @@ import { getUnsubLink } from "../utils/get-unsub-link"; import { getSiteUrl } from "../utils/get-site-url"; import { jwtUtils } from "@courselit/utils"; import { JSDOM } from "jsdom"; +import { DomainDocument } from "./model/domain"; const liquidEngine = new Liquid(); new Worker( @@ -182,7 +183,7 @@ async function attemptMailSending({ sequence: AdminSequence; ongoingSequence: OngoingSequence; email: Email; - domain: Domain; + domain: DomainDocument; }) { const from = sequence.from ? `${sequence.from.name} <${creator.email}>` @@ -281,7 +282,7 @@ function transformLinksForClickTracking( userId: string, sequenceId: string, emailId: string, - domain: Domain, + domain: DomainDocument, ): string { try { const dom = new JSDOM(htmlContent); diff --git a/apps/queue/src/domain/process-rules.ts b/apps/queue/src/domain/process-rules.ts index 77125bb82..91a603401 100644 --- a/apps/queue/src/domain/process-rules.ts +++ b/apps/queue/src/domain/process-rules.ts @@ -11,7 +11,9 @@ import { convertFiltersToDBConditions, } from "@courselit/common-logic"; -type RuleWithDomain = Rule & { domain: mongoose.Schema.Types.ObjectId }; +type RuleWithDomain = Omit & { + domain: mongoose.Types.ObjectId; +}; export async function processRules() { // eslint-disable-next-line no-constant-condition @@ -70,7 +72,9 @@ async function processRule(rule: RuleWithDomain) { } async function addBroadcastToOngoingSequence(sequence: AdminSequence) { - const query: Partial = { + const query: Partial> & { + domain: mongoose.Types.ObjectId; + } = { domain: sequence.domain, ...(await convertFiltersToDBConditions({ domain: sequence.domain, diff --git a/apps/queue/src/domain/queries.ts b/apps/queue/src/domain/queries.ts index 2f1fbd991..68eda83b3 100644 --- a/apps/queue/src/domain/queries.ts +++ b/apps/queue/src/domain/queries.ts @@ -7,7 +7,7 @@ import MembershipModel from "./model/membership"; import UserModel from "./model/user"; import RuleModel from "./model/rule"; import mongoose from "mongoose"; -import DomainModel from "./model/domain"; +import DomainModel, { DomainDocument } from "./model/domain"; import { Constants, EmailTemplate } from "@courselit/common-models"; import emailTemplate from "./model/email-template"; import { @@ -62,8 +62,9 @@ export async function updateSequenceSentAt(sequenceId: string): Promise { ); } -export async function getDomain(id: mongoose.Types.ObjectId) { - // @ts-ignore - Mongoose type compatibility issue +export async function getDomain( + id: mongoose.Types.ObjectId, +): Promise { return await DomainModel.findById(id); } diff --git a/apps/queue/src/index.ts b/apps/queue/src/index.ts index 437e98a3d..806350bca 100644 --- a/apps/queue/src/index.ts +++ b/apps/queue/src/index.ts @@ -17,7 +17,7 @@ app.use("/job", verifyJWTMiddleware, jobRoutes); app.use("/sse", sseRoutes); app.get("/healthy", (req, res) => { - res.status(200).json({ success: true }); + res.status(200).json({ status: "ok", uptime: process.uptime() }); }); startEmailAutomation(); diff --git a/apps/web/.eslintrc.json b/apps/web/.eslintrc.json deleted file mode 100644 index b80a3da40..000000000 --- a/apps/web/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": ["next", "prettier"] -} diff --git a/apps/web/.migrations/10-11-25_00-40-migrate-lesson-creatorid-to-userid.js b/apps/web/.migrations/10-11-25_00-40-migrate-lesson-creatorid-to-userid.js new file mode 100644 index 000000000..f22aa7750 --- /dev/null +++ b/apps/web/.migrations/10-11-25_00-40-migrate-lesson-creatorid-to-userid.js @@ -0,0 +1,96 @@ +import mongoose from "mongoose"; + +function generateUniqueId() { + return nanoid(); +} + +mongoose.connect(process.env.DB_CONNECTION_STRING, { + useNewUrlParser: true, + useUnifiedTopology: true, +}); + +const CourseSchema = new mongoose.Schema( + { + domain: { type: mongoose.Schema.Types.ObjectId, required: true }, + courseId: { type: String, required: true, default: generateUniqueId }, + creatorId: { type: String, required: true }, + groups: [ + { + _id: { + type: String, + required: true, + default: generateUniqueId, + }, + }, + ], + }, + { + timestamps: true, + }, +); +const Course = mongoose.model("Course", CourseSchema); + +const LessonSchema = new mongoose.Schema({ + domain: { type: mongoose.Schema.Types.ObjectId, required: true }, + lessonId: { type: String, required: true, default: generateUniqueId }, + creatorId: { type: String, required: true }, + courseId: { type: String, required: true }, + groupId: { type: String, required: true }, +}); + +const Lesson = mongoose.model("Lesson", LessonSchema); + +async function migrateLessonCreatorIdToUserId() { + const courses = await Course.find({}); + for (const course of courses) { + console.log(`Updating lessons for course ${course.courseId}`); + await Lesson.updateMany( + { + domain: course.domain, + courseId: course.courseId, + }, + { + $set: { + creatorId: course.creatorId, + }, + }, + ); + console.log(`Updated lessons for course ${course.courseId}`); + } +} + +async function deleteOrphanLessons() { + const courses = await Course.find({}).lean(); + for (const course of courses) { + const groupsIds = course.groups.map((group) => group._id); + const lessons = await Lesson.find({ + domain: course.domain, + courseId: course.courseId, + }).lean(); + + const orphanLessons = lessons.filter( + (lesson) => !groupsIds.includes(lesson.groupId), + ); + + if (orphanLessons.length > 0) { + console.log( + `Detected ${orphanLessons.length} orphan lessons for course ${course.courseId}`, + ); + const query = { + _id: { + $in: orphanLessons.map((lesson) => lesson._id), + }, + }; + await Lesson.deleteMany(query); + console.log( + `Deleted ${orphanLessons.length} orphan lessons for course ${course.courseId}`, + ); + } + } +} + +(async () => { + await migrateLessonCreatorIdToUserId(); + await deleteOrphanLessons(); + mongoose.connection.close(); +})(); diff --git a/apps/web/__mocks__/medialit.ts b/apps/web/__mocks__/medialit.ts new file mode 100644 index 000000000..5bc291a7c --- /dev/null +++ b/apps/web/__mocks__/medialit.ts @@ -0,0 +1,28 @@ +class MediaLit { + endpoint: string; + constructor(config: { endpoint?: string }) { + this.endpoint = config.endpoint || "https://medialit.example.com"; + } + + async get(mediaId: string) { + return { + mediaId, + file: "mock-file", + originalFileName: "mock-file", + mimeType: "image/png", + size: 0, + access: "public", + url: `https://medialit.example.com/${mediaId}/main.png`, + }; + } + + async getSignature(_: { group: string }) { + return "mock-signature"; + } + + async delete(_: string) { + return true; + } +} + +export { MediaLit }; diff --git a/apps/web/app/(with-contexts)/(with-layout)/accomplishment/[certId]/page.tsx b/apps/web/app/(with-contexts)/(with-layout)/accomplishment/[certId]/page.tsx new file mode 100644 index 000000000..f2ba93add --- /dev/null +++ b/apps/web/app/(with-contexts)/(with-layout)/accomplishment/[certId]/page.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { + Badge, + Button, + Header1, + Header2, + Section, + Text1, +} from "@courselit/page-primitives"; +import { redirect, useParams } from "next/navigation"; +import { ThemeContext } from "@components/contexts"; +import { useContext, useRef, useState } from "react"; +import { BadgeCheck, ExternalLinkIcon, Printer } from "lucide-react"; +import Link from "next/link"; +import { Image } from "@courselit/components-library"; +import { formattedLocaleDate } from "@ui-lib/utils"; +import { useCertificate } from "@/hooks/use-certificate"; +import { Skeleton } from "@/components/ui/skeleton"; + +export default function AccomplishmentPage() { + const params = useParams(); + const certId = params.certId; + const { theme } = useContext(ThemeContext); + const [isIframeLoaded, setIsIframeLoaded] = useState(false); + const iframeRef = useRef(null); + const { certificate, loaded } = useCertificate(certId as string); + + const handlePrint = () => { + const iframeElement = iframeRef.current; + if (!iframeElement) return; + const iframeWindow = iframeElement.contentWindow; + if (!iframeWindow) return; + iframeWindow.focus(); + iframeWindow.print(); + }; + + if (loaded && !certificate) { + redirect("/"); + } + + if (!certificate) { + return ( +
+
+
+ + +
+ + {/* User completion info skeleton */} +
+ +
+ + +
+
+ + {/* Certificate section header + viewer skeleton */} +
+ + +
+
+
+ ); + } + + return ( +
+
+
+ + {certificate.productTitle} + +
+ + + Visit + + +
+
+ + {/* User completion info - responsive layout */} +
+ {certificate.userImage && ( +
+ {certificate.userName} +
+ )} +
+ + + Completed by{" "} + + {certificate.userName} + + + + {formattedLocaleDate(+certificate.createdAt)} + +
+
+ + {/* Certificate section header - responsive */} +
+
+ Certificate + +
+ +
+ {!isIframeLoaded && ( + + )} +
+
+ ); } + if (!media.url) { + return null; + } return (
) => { + const updateBanner = async (json: TextEditorContent) => { const query = ` mutation UpdateCommunity( $id: String! @@ -832,7 +733,9 @@ export function CommunityForum({ query, variables: { id, - banner: JSON.stringify(json), + banner: JSON.stringify( + json as unknown as Record, + ), }, }) .setIsGraphQLEndpoint(true) @@ -914,7 +817,7 @@ export function CommunityForum({ } }; - if (!loaded) { + if (!loaded || !profile) { return ; } @@ -1030,7 +933,13 @@ export function CommunityForum({ {!community?.enabled && (
This community is not enabled. It is not visible to your - audience (including moderators). + audience (including moderators). {""} + + {MANAGE_LINK_TEXT} +
)}
@@ -1046,7 +955,12 @@ export function CommunityForum({ categories={categories.filter( (x) => x !== "All", )} - onPostCreated={createPost} + createPost={createPost} + isFileUploading={isUploading} + fileUploadProgress={uploadProgress} + fileBeingUploadedNumber={ + fileBeingUploadedNumber + } /> ) : null ) : ( @@ -1055,7 +969,7 @@ export function CommunityForum({ membership={membership} joiningReasonText={community?.joiningReasonText} key={refreshCommunityStatus} - paymentPlan={community?.paymentPlans.find( + paymentPlan={community?.paymentPlans?.find( (plan) => plan.planId === community?.defaultPaymentPlan, @@ -1069,7 +983,7 @@ export function CommunityForum({ key={category} variant={ category === activeCategory - ? "primary" + ? "default" : "outline" } size="sm" @@ -1098,7 +1012,9 @@ export function CommunityForum({ ) : false } - initialBannerText={community?.banner} + initialBannerText={ + community?.banner as TextEditorContent | undefined + } onSaveBanner={updateBanner} /> @@ -1154,7 +1070,7 @@ export function CommunityForum({ .email}
- {formattedLocaleDate( + {formatTimestamp( post.updatedAt, )}{" "} •{" "} @@ -1295,7 +1211,7 @@ export function CommunityForum({ .email}
- {formattedLocaleDate( + {formatTimestamp( post.updatedAt, )}{" "} •{" "} @@ -1430,29 +1346,32 @@ export function CommunityForum({ {post.commentsCount}
- { - setPosts((prevPosts) => - prevPosts.map( - (p) => - p.postId === - postId - ? { - ...p, - commentsCount: - count, - } - : p, - ), - ); - }} - /> + {membership && ( + { + setPosts( + (prevPosts) => + prevPosts.map( + (p) => + p.postId === + postId + ? { + ...p, + commentsCount: + count, + } + : p, + ), + ); + }} + /> + )}
@@ -1540,13 +1459,11 @@ export function CommunityForum({ } memberCount={community?.membersCount} membership={membership} - paymentPlan={ - community?.paymentPlans.find( - (plan) => - plan.planId === - community?.defaultPaymentPlan, - )! - } + paymentPlan={community?.paymentPlans?.find( + (plan) => + plan.planId === + community?.defaultPaymentPlan, + )} joiningReasonText={community?.joiningReasonText} pageId={community?.pageId} onJoin={handleJoin} diff --git a/apps/web/components/community/info.tsx b/apps/web/components/community/info.tsx index ea77efac8..fb609f036 100644 --- a/apps/web/components/community/info.tsx +++ b/apps/web/components/community/info.tsx @@ -41,7 +41,7 @@ interface CommunityInfoProps { description: Record; image: string; memberCount: number; - paymentPlan: PaymentPlan; + paymentPlan?: PaymentPlan; joiningReasonText?: string; pageId: string; onJoin: (joiningReason?: string) => void; @@ -65,7 +65,9 @@ export function CommunityInfo({ const [showLeaveConfirmation, setShowLeaveConfirmation] = useState(false); const [isJoinDialogOpen, setIsJoinDialogOpen] = useState(false); const [joiningReason, setJoiningReason] = useState(""); - const { amount, period } = getPlanPrice(paymentPlan); + const { amount, period } = paymentPlan + ? getPlanPrice(paymentPlan) + : { amount: 0, period: "" }; const address = useContext(AddressContext); const siteinfo = useContext(SiteInfoContext); const { profile } = useContext(ProfileContext); @@ -99,7 +101,7 @@ export function CommunityInfo({ -
+

{name}

- - {preview && ( - - )} -
- {recommendedSize && ( -

- Recommended size: {recommendedSize} -

- )} - {error && ( -

{error}

- )} -
-
-
- ); -} diff --git a/apps/web/components/notifications-viewer.tsx b/apps/web/components/notifications-viewer.tsx index 7932fef37..0b9c77e92 100644 --- a/apps/web/components/notifications-viewer.tsx +++ b/apps/web/components/notifications-viewer.tsx @@ -1,6 +1,13 @@ "use client"; -import { useContext, useEffect, useState } from "react"; +import { + useContext, + useEffect, + useState, + useCallback, + startTransition, + useMemo, +} from "react"; import { Bell, ChevronLeft, @@ -55,12 +62,53 @@ export function NotificationsViewer() { setCurrentPage((prev) => Math.min(prev + 1, totalPages)); const prevPage = () => setCurrentPage((prev) => Math.max(prev - 1, 1)); - const fetch = new FetchBuilder() - .setUrl(`${address.backend}/api/graph`) - .setIsGraphQLEndpoint(true); + const fetchBuilder = useMemo( + () => + new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setIsGraphQLEndpoint(true), + [address.backend], + ); + + const getNotification = useCallback( + async (notificationId: string) => { + const query = ` + query ($notificationId: String!) { + notification: getNotification(notificationId: $notificationId) { + notificationId + message + href + read + createdAt + } + } + `; + + const fetcher = fetchBuilder + .setPayload({ + query, + variables: { + notificationId, + }, + }) + .build(); + try { + const response = await fetcher.exec(); + startTransition(() => { + setNotifications((prev) => [ + response.notification, + ...prev, + ]); + }); + } catch (error) { + console.error(error); + } + }, + [fetchBuilder], + ); useEffect(() => { - if (!profile.userId || !config.queueServer) { + if (!profile?.userId || !config.queueServer) { return; } @@ -76,36 +124,7 @@ export function NotificationsViewer() { return () => { eventSource.close(); }; - }, [profile, config]); - - const getNotification = async (notificationId: string) => { - const query = ` - query ($notificationId: String!) { - notification: getNotification(notificationId: $notificationId) { - notificationId - message - href - read - createdAt - } - } - `; - - const fetcher = fetch - .setPayload({ - query, - variables: { - notificationId, - }, - }) - .build(); - try { - const response = await fetcher.exec(); - setNotifications((prev) => [response.notification, ...prev]); - } catch (error) { - console.error(error); - } - }; + }, [profile?.userId, config.queueServer, getNotification]); const markAllAsRead = () => { const mutation = ` @@ -114,10 +133,14 @@ export function NotificationsViewer() { } `; - const fetcher = fetch.setPayload({ query: mutation }).build(); + const fetcher = fetchBuilder.setPayload({ query: mutation }).build(); try { fetcher.exec(); - setNotifications(notifications.map((n) => ({ ...n, read: true }))); + startTransition(() => { + setNotifications((prev) => + prev.map((n) => ({ ...n, read: true })), + ); + }); } catch (error) { toast({ title: "Error", @@ -144,7 +167,7 @@ export function NotificationsViewer() { } `; - const fetcher = await fetch + const fetcher = await fetchBuilder .setPayload({ query, variables: { @@ -156,14 +179,14 @@ export function NotificationsViewer() { try { const response = await fetcher.exec(); if (response.notifications) { - setNotifications( - (prev) => response.notifications.notifications, - ); - setTotalPages( - Math.ceil( - response.notifications.total / ITEMS_PER_PAGE, - ), - ); + startTransition(() => { + setNotifications(response.notifications.notifications); + setTotalPages( + Math.ceil( + response.notifications.total / ITEMS_PER_PAGE, + ), + ); + }); } } catch (error) { console.error(error); @@ -171,7 +194,7 @@ export function NotificationsViewer() { }; fetchNotifications(); - }, [currentPage]); + }, [currentPage, fetchBuilder]); const markReadAndNavigate = async ( notificationId: string, @@ -183,7 +206,7 @@ export function NotificationsViewer() { } `; - const fetcher = await fetch + const fetcher = await fetchBuilder .setPayload({ query, variables: { diff --git a/apps/web/components/public/article.tsx b/apps/web/components/public/article.tsx deleted file mode 100644 index d0c8a2858..000000000 --- a/apps/web/components/public/article.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import React from "react"; -import { formattedLocaleDate, truncate } from "../../ui-lib/utils"; -import { connect } from "react-redux"; -import { - Image, - TextRenderer, - TextEditorEmptyDoc, - Avatar, - AvatarImage, - AvatarFallback, -} from "@courselit/components-library"; -import { AppState } from "@courselit/state-management"; -import { Course, Profile, SiteInfo } from "@courselit/common-models"; -import { BLOG_UPDATED_PREFIX, UNNAMED_USER } from "@ui-config/strings"; - -interface ArticleProps { - course: Course; - options?: ArticleOptionsProps; - profile: Profile; - siteInfo: SiteInfo; -} - -interface ArticleOptionsProps { - showAttribution?: boolean; - hideTitle?: boolean; -} - -export const Article = (props: ArticleProps) => { - const { course, options = { hideTitle: false }, profile } = props; - - return ( -
-
- {!options?.hideTitle && ( -

{course.title}

- )} - {options?.showAttribution && ( -
-
- - - - {(profile.name - ? profile.name.charAt(0) - : profile.email.charAt(0) - ).toUpperCase()} - - -
-
-

- {truncate( - course.creatorName || UNNAMED_USER, - 50, - )} -

-

- - {BLOG_UPDATED_PREFIX}: - {" "} - {formattedLocaleDate(course.updatedAt, "long")} -

-
-
- )} -
- {course.featuredImage && ( -
-
- {course.featuredImage.caption} -
-
- )} -
- -
- {/* - {course.tags.length > 0 && ( - - - Tags - - - {course.tags.map((tag: string) => ( - - - - ))} - - - )} - */} -
- ); -}; - -const mapStateToProps = (state: AppState) => ({ - profile: state.profile, - siteInfo: state.siteinfo, -}); - -export default connect(mapStateToProps)(Article); diff --git a/apps/web/components/public/base-layout/branding.tsx b/apps/web/components/public/base-layout/branding.tsx index 8642be10d..c08c44770 100644 --- a/apps/web/components/public/base-layout/branding.tsx +++ b/apps/web/components/public/base-layout/branding.tsx @@ -1,6 +1,4 @@ import React from "react"; -import { connect } from "react-redux"; -import type { AppState } from "@courselit/state-management"; import { Image, Link } from "@courselit/components-library"; import { SiteInfo } from "@courselit/common-models"; @@ -8,7 +6,7 @@ interface BrandingProps { siteinfo: SiteInfo; } -const Branding = ({ siteinfo }: BrandingProps) => { +export default function Branding({ siteinfo }: BrandingProps) { return (
@@ -25,10 +23,4 @@ const Branding = ({ siteinfo }: BrandingProps) => {
); -}; - -const mapStateToProps = (state: AppState) => ({ - siteinfo: state.siteinfo, -}); - -export default connect(mapStateToProps)(Branding); +} diff --git a/apps/web/components/public/base-layout/header.tsx b/apps/web/components/public/base-layout/header.tsx index d487485a1..fb959c1c2 100644 --- a/apps/web/components/public/base-layout/header.tsx +++ b/apps/web/components/public/base-layout/header.tsx @@ -1,24 +1,16 @@ import React from "react"; import { IconButton } from "@courselit/components-library"; import { Menu } from "@courselit/icons"; -import SessionButton from "../session-button"; import Branding from "./branding"; import ExitCourseButton from "./exit-course-button"; -import { useRouter } from "next/router"; +import { SiteInfo } from "@courselit/common-models"; interface HeaderProps { onMenuClick?: (...args: any[]) => void; + siteinfo: SiteInfo; } -const Header = ({ onMenuClick }: HeaderProps) => { - const router = useRouter(); - const currentCoursePathName = router.pathname; - - const coursePathName = [ - "/course/[slug]/[id]", - "/course/[slug]/[id]/[lesson]", - ]; - +const Header = ({ onMenuClick, siteinfo }: HeaderProps) => { return (
{onMenuClick && ( @@ -30,12 +22,8 @@ const Header = ({ onMenuClick }: HeaderProps) => { )} - - {coursePathName.includes(currentCoursePathName) ? ( - - ) : ( - - )} + +
); }; diff --git a/apps/web/components/public/base-layout/index.tsx b/apps/web/components/public/base-layout/index.tsx index 6d75cb017..0a3ce9a1b 100644 --- a/apps/web/components/public/base-layout/index.tsx +++ b/apps/web/components/public/base-layout/index.tsx @@ -1,15 +1,7 @@ import React, { ReactNode } from "react"; -import { connect } from "react-redux"; import Head from "next/head"; import Template from "./template"; -import { - actionCreators, - AppDispatch, - AppState, -} from "@courselit/state-management"; -import type { Media, Typeface, WidgetInstance } from "@courselit/common-models"; -import { useSession } from "next-auth/react"; -import { useEffect } from "react"; +import type { Media, State, WidgetInstance } from "@courselit/common-models"; import { Theme } from "@courselit/page-models"; interface BaseLayoutProps { @@ -19,22 +11,18 @@ interface BaseLayoutProps { pageData?: Record; children?: ReactNode; childrenOnTop?: boolean; - typefaces: Typeface[]; - dispatch: AppDispatch; description?: string; socialImage?: Media; robotsAllowed?: boolean; - state: AppState; + state: State; theme: Theme; } -export const BaseLayout = ({ +export default function BaseLayout({ title, siteInfo, children, layout, - typefaces, - dispatch, pageData = {}, childrenOnTop = false, description, @@ -42,19 +30,8 @@ export const BaseLayout = ({ robotsAllowed = true, state, theme, -}: BaseLayoutProps) => { - const { status } = useSession(); - state.theme = theme; - - useEffect(() => { - if (status === "authenticated") { - dispatch(actionCreators.signedIn()); - dispatch(actionCreators.authChecked()); - } - if (status === "unauthenticated") { - dispatch(actionCreators.authChecked()); - } - }, [status]); +}: BaseLayoutProps) { + const stateWithTheme = { ...state, theme }; const siteTitle = title || siteInfo.title; const siteDescription = description || siteInfo.subtitle; @@ -120,24 +97,10 @@ export const BaseLayout = ({ layout={layout} childrenOnTop={childrenOnTop} pageData={pageData} - state={state} - dispatch={dispatch} + state={stateWithTheme} > {children} ); -}; - -const mapStateToProps = (state: AppState) => ({ - networkAction: state.networkAction, - siteInfo: state.siteinfo, - address: state.address, - typefaces: state.typefaces, - state: state, - theme: state.theme, -}); - -const mapDispatchToProps = (dispatch: AppDispatch) => ({ dispatch }); - -export default connect(mapStateToProps, mapDispatchToProps)(BaseLayout); +} diff --git a/apps/web/components/public/base-layout/template/editable-widget.tsx b/apps/web/components/public/base-layout/template/editable-widget.tsx index f0e26eeb2..bde834229 100644 --- a/apps/web/components/public/base-layout/template/editable-widget.tsx +++ b/apps/web/components/public/base-layout/template/editable-widget.tsx @@ -1,5 +1,8 @@ -import { AppDispatch, AppState } from "@courselit/state-management"; -import { WidgetDefaultSettings, WidgetProps } from "@courselit/common-models"; +import { + State, + WidgetDefaultSettings, + WidgetProps, +} from "@courselit/common-models"; import WidgetByName from "./widget-by-name"; import { Tooltip } from "@courselit/components-library"; import { Button } from "@/components/ui/button"; @@ -17,7 +20,6 @@ const EditableWidget = ({ onAddWidgetBelow, onMoveWidgetUp, onMoveWidgetDown, - dispatch, state, }: { item: Record; @@ -31,8 +33,7 @@ const EditableWidget = ({ onAddWidgetBelow?: (index: number) => void; onMoveWidgetUp?: (index: number) => void; onMoveWidgetDown?: (index: number) => void; - state: Partial; - dispatch?: AppDispatch; + state: State; }) => { if (editing) { return ( @@ -40,7 +41,7 @@ const EditableWidget = ({ onClick={() => onEditClick && onEditClick(item.widgetId)} className="relative cursor-pointer group" > -
+
Click to update
@@ -51,8 +52,7 @@ const EditableWidget = ({ pageData={pageData} id={item.widgetId} editing={true} - dispatch={dispatch} - state={state as AppState} + state={state} />
{allowsUpwardMovement && ( @@ -108,8 +108,7 @@ const EditableWidget = ({ settings={item.settings || {}} pageData={pageData} id={item.widgetId} - dispatch={dispatch} - state={state as AppState} + state={state} editing={false} /> ); diff --git a/apps/web/components/public/base-layout/template/index.tsx b/apps/web/components/public/base-layout/template/index.tsx index e292a3677..55534edb9 100644 --- a/apps/web/components/public/base-layout/template/index.tsx +++ b/apps/web/components/public/base-layout/template/index.tsx @@ -1,8 +1,7 @@ import React, { ReactNode } from "react"; -import { WidgetInstance } from "@courselit/common-models"; +import { State, WidgetInstance } from "@courselit/common-models"; import { Footer, Header } from "@courselit/page-blocks"; import { Toaster } from "@courselit/components-library"; -import { AppDispatch, AppState } from "@courselit/state-management"; import EditableWidget from "./editable-widget"; import { generateThemeStyles } from "@/lib/theme-styles"; import { Theme } from "@courselit/page-models"; @@ -17,12 +16,11 @@ interface TemplateProps { editing?: boolean; onEditClick?: (widgetId: string) => void; children?: ReactNode; - childrenOnTop: boolean; + childrenOnTop?: boolean; onAddWidgetBelow?: (index: number) => void; onMoveWidgetUp?: (index: number) => void; onMoveWidgetDown?: (index: number) => void; - dispatch?: AppDispatch; - state: Partial; + state: State; id?: string; injectThemeStyles?: boolean; } @@ -38,10 +36,14 @@ const Template = (props: TemplateProps) => { onAddWidgetBelow, onMoveWidgetUp, onMoveWidgetDown, - dispatch, state, } = props; + const normalizedPageData = { + ...pageData, + pageType: pageData.pageType ?? "site", + } as PageData & { pageType: "product" | "site" | "blog" | "community" }; + if (!layout) return <>; const footer = layout.filter( (widget) => widget.name === Footer.metadata.name, @@ -49,9 +51,9 @@ const Template = (props: TemplateProps) => { const header = layout.filter( (widget) => widget.name === Header.metadata.name, )[0]; + const headerFooterNames = [Header.metadata.name, Footer.metadata.name]; const widgetsWithoutHeaderAndFooter = layout.filter( - (widget) => - ![Header.metadata.name, Footer.metadata.name].includes(widget.name), + (widget) => !headerFooterNames.includes(widget.name ?? ""), ); const pageWidgets = widgetsWithoutHeaderAndFooter.map( (item: any, index: number) => ( @@ -60,7 +62,7 @@ const Template = (props: TemplateProps) => { key={item.widgetId} editing={editing} onEditClick={onEditClick} - pageData={pageData} + pageData={normalizedPageData} allowsWidgetAddition={true} allowsUpwardMovement={index !== 0} allowsDownwardMovement={ @@ -70,26 +72,24 @@ const Template = (props: TemplateProps) => { onMoveWidgetDown={onMoveWidgetDown} onMoveWidgetUp={onMoveWidgetUp} index={index + 1} - dispatch={dispatch} state={state} /> ), ); return ( -
+
{header && ( )} @@ -108,11 +108,10 @@ const Template = (props: TemplateProps) => { {footer && ( )} diff --git a/apps/web/components/public/base-layout/template/widget-by-name.tsx b/apps/web/components/public/base-layout/template/widget-by-name.tsx index ff6672dda..8309bf4a1 100644 --- a/apps/web/components/public/base-layout/template/widget-by-name.tsx +++ b/apps/web/components/public/base-layout/template/widget-by-name.tsx @@ -9,7 +9,6 @@ const WidgetByName = ({ id, name, state, - dispatch, settings, pageData, editing = false, @@ -25,7 +24,6 @@ const WidgetByName = ({ name, settings, state, - dispatch, id, pageData, editing, diff --git a/apps/web/components/public/base-layout/template/widget-error-boundary.tsx b/apps/web/components/public/base-layout/template/widget-error-boundary.tsx index c8dfcb518..3e96fd031 100644 --- a/apps/web/components/public/base-layout/template/widget-error-boundary.tsx +++ b/apps/web/components/public/base-layout/template/widget-error-boundary.tsx @@ -1,4 +1,4 @@ -import React, { Component, ErrorInfo, ReactNode } from "react"; +import React, { Component, ErrorInfo, ReactNode, type JSX } from "react"; import { capitalize } from "@courselit/utils"; interface Props { diff --git a/apps/web/components/public/checkout/free.tsx b/apps/web/components/public/checkout/free.tsx deleted file mode 100644 index aac66d8c9..000000000 --- a/apps/web/components/public/checkout/free.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import React, { useState } from "react"; -import { - ENROLL_BUTTON_TEXT, - TOAST_TITLE_ERROR, -} from "../../../ui-config/strings"; -import { connect } from "react-redux"; -import { useRouter } from "next/router"; -import { actionCreators } from "@courselit/state-management"; -import type { Address, Course } from "@courselit/common-models"; -import type { AppDispatch, AppState } from "@courselit/state-management"; -import { FetchBuilder } from "@courselit/utils"; -import { refreshUserProfile } from "@courselit/state-management/dist/action-creators"; -import { Button2, useToast } from "@courselit/components-library"; - -const { networkAction } = actionCreators; - -interface FreeProps { - course: Course; - dispatch: AppDispatch; - address: Address; -} - -const Free = ({ course, dispatch, address }: FreeProps) => { - const router = useRouter(); - const [disabled, setDisabled] = useState(false); - const { toast } = useToast(); - - const handleClick = async () => { - const payload = { - courseid: course.courseId, - }; - const fetch = new FetchBuilder() - .setUrl(`${address.backend}/api/payment/initiate`) - .setHeaders({ - "Content-Type": "application/json", - }) - .setPayload(JSON.stringify(payload)) - .build(); - - try { - setDisabled(true); - dispatch(networkAction(true)); - - const response = await fetch.exec({ - redirectToOnUnAuth: router.asPath, - }); - - if (response.status === "success") { - dispatch(refreshUserProfile()); - router.replace(`/dashboard/my-content`); - } else if (response.status === "failed") { - toast({ - title: TOAST_TITLE_ERROR, - description: response.error, - variant: "destructive", - }); - } - } catch (err: any) { - toast({ - title: TOAST_TITLE_ERROR, - description: err.message, - variant: "destructive", - }); - } finally { - dispatch(networkAction(false)); - setDisabled(false); - } - }; - - return ( - - {ENROLL_BUTTON_TEXT} - - ); -}; - -const mapStateToProps = (state: AppState) => ({ - address: state.address, -}); - -const mapDispatchToProps = (dispatch: AppDispatch) => ({ - dispatch: dispatch, -}); - -export default connect(mapStateToProps, mapDispatchToProps)(Free); diff --git a/apps/web/components/public/checkout/index.tsx b/apps/web/components/public/checkout/index.tsx deleted file mode 100644 index 88ed1c8fd..000000000 --- a/apps/web/components/public/checkout/index.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React from "react"; -import { connect } from "react-redux"; -import dynamic from "next/dynamic"; -import { Course, SiteInfo, UIConstants } from "@courselit/common-models"; -import { AppState } from "@courselit/state-management"; - -const Stripe = dynamic(() => import("./stripe")); -const Razorpay = dynamic(() => import("./razorpay")); -const Free = dynamic(() => import("./free")); -const Lemonsqueezy = dynamic(() => import("./lemonsqueezy")); - -interface CheckoutExternalProps { - course: Course; - siteInfo: SiteInfo; -} - -const CheckoutExternal = (props: CheckoutExternalProps) => { - const { course } = props; - const { paymentMethod } = props.siteInfo; - - return ( -
- {course.cost === 0 && } - {course.cost !== 0 && ( - <> - {paymentMethod === UIConstants.PAYMENT_METHOD_STRIPE && ( - - )} - {paymentMethod === UIConstants.PAYMENT_METHOD_RAZORPAY && ( - - )} - {paymentMethod === - UIConstants.PAYMENT_METHOD_LEMONSQUEEZY && ( - - )} - {paymentMethod === UIConstants.PAYMENT_METHOD_PAYTM && ( - <> - )} - {paymentMethod === UIConstants.PAYMENT_METHOD_PAYPAL && ( - <> - )} - - )} -
- ); -}; - -const mapStateToProps = (state: AppState) => ({ - auth: state.auth, - siteInfo: state.siteinfo, -}); - -export default connect(mapStateToProps)(CheckoutExternal); diff --git a/apps/web/components/public/checkout/lemonsqueezy.tsx b/apps/web/components/public/checkout/lemonsqueezy.tsx deleted file mode 100644 index 2e438e976..000000000 --- a/apps/web/components/public/checkout/lemonsqueezy.tsx +++ /dev/null @@ -1,102 +0,0 @@ -"use client"; - -import React, { useEffect, useState } from "react"; -import { Button2, useToast } from "@courselit/components-library"; -import { - ENROLL_BUTTON_TEXT, - TOAST_TITLE_ERROR, - WORKING, -} from "../../../ui-config/strings"; -import { connect } from "react-redux"; -import { useRouter } from "next/router"; -import type { AppState, AppDispatch } from "@courselit/state-management"; -import { Address, Course } from "@courselit/common-models"; -import { FetchBuilder } from "@courselit/utils"; -import { actionCreators } from "@courselit/state-management"; -import Script from "next/script"; - -const { networkAction } = actionCreators; - -interface LemonsqueezyProps { - course: Course; - address: Address; - dispatch: AppDispatch; -} - -const Lemonsqueezy = (props: LemonsqueezyProps) => { - const { course, address, dispatch } = props; - const router = useRouter(); - const { toast } = useToast(); - const [loading, setLoading] = useState(false); - - const handleClick = async () => { - const payload = { - courseid: course.courseId, - metadata: JSON.stringify({ - successUrl: `${address.frontend}/checkout/${course.courseId}`, - sourceUrl: `/course/${course.slug}/${course.courseId}`, - }), - }; - const fetch = new FetchBuilder() - .setUrl(`${address.backend}/api/payment/initiate`) - .setHeaders({ - "Content-Type": "application/json", - }) - .setPayload(JSON.stringify(payload)) - .build(); - - try { - dispatch(networkAction(true)); - setLoading(true); - const response = await fetch.exec({ - redirectToOnUnAuth: router.asPath, - }); - dispatch(networkAction(false)); - if (response.status === "initiated") { - (window as any).LemonSqueezy.Url.Open(response.paymentTracker); - } else if (response.status === "success") { - router.replace(`/course/${course.slug}/${course.courseId}`); - } - } catch (err: any) { - toast({ - title: TOAST_TITLE_ERROR, - description: err.message, - variant: "destructive", - }); - } finally { - dispatch(networkAction(false)); - setLoading(false); - } - }; - - useEffect(() => { - function setupLemonSqueezy() { - if (typeof (window as any).createLemonSqueezy !== "undefined") { - (window as any).createLemonSqueezy(); - } - } - - setupLemonSqueezy(); - }); - - return ( - <> - - {loading ? WORKING : ENROLL_BUTTON_TEXT} - -