diff --git a/.cursor/rules/basics.mdc b/.cursor/rules/basics.mdc index ff7b506e4..882945ab0 100644 --- a/.cursor/rules/basics.mdc +++ b/.cursor/rules/basics.mdc @@ -7,5 +7,5 @@ alwaysApply: true - 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. +- 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/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/src/pages/en/website/blocks.md b/apps/docs/src/pages/en/website/blocks.md index 614efceb2..9f7bed0b9 100644 --- a/apps/docs/src/pages/en/website/blocks.md +++ b/apps/docs/src/pages/en/website/blocks.md @@ -36,7 +36,7 @@ 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) - + ### [Rich Text](#rich-text) @@ -61,7 +61,7 @@ You can also use the floating controls to do the same as shown below. 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) - + ### [Hero](#hero) @@ -87,7 +87,7 @@ Following is how it looks on a page. 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`. - + ### [Grid](#grid) @@ -132,7 +132,7 @@ A grid block comes in handy when you want to show some sort of list, for example 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`. - + ### [Featured](#featured) @@ -268,7 +268,7 @@ In the `Design` panel, you can customize: - Maximum width - Vertical padding - Social media links (Facebook, Twitter, Instagram, LinkedIn, YouTube, Discord, GitHub) - + ## [Shared blocks](#shared-blocks) diff --git a/apps/docs/src/pages/en/website/themes.md b/apps/docs/src/pages/en/website/themes.md index 2d7bed189..0700d2e29 100644 --- a/apps/docs/src/pages/en/website/themes.md +++ b/apps/docs/src/pages/en/website/themes.md @@ -192,14 +192,14 @@ The typography editor lets you customize text styles across your website. These - Header 3: Smaller titles for subsections - Header 4: Small titles for minor sections - Preheader: Introductory text that appears above headers - +
Subheaders - Subheader 1: Primary subheaders for section introductions - Subheader 2: Secondary subheaders for supporting text -
+
Body Text @@ -207,7 +207,7 @@ The typography editor lets you customize text styles across your website. These - Text 1: Main body text for content - Text 2: Secondary body text for supporting content - Caption: Small text for image captions and footnotes -
+
Interactive Elements @@ -215,7 +215,7 @@ The typography editor lets you customize text styles across your website. These - Link: Text for clickable links - Button: Text for buttons and calls-to-action - Input: Text for form fields and search boxes -
+ For each text style, you can customize: @@ -243,7 +243,7 @@ CourseLit provides a carefully curated selection of professional fonts, organize - **Mulish**: A geometric sans-serif with a modern feel - **Nunito**: A well-balanced font with rounded terminals - **Work Sans**: A clean, modern font with a geometric feel - +
Serif Fonts @@ -253,7 +253,7 @@ CourseLit provides a carefully curated selection of professional fonts, organize - **Playfair Display**: An elegant serif font for headings - **Roboto Slab**: A serif variant of Roboto - **Source Serif 4**: A serif font designed for digital reading -
+
Display Fonts @@ -264,7 +264,7 @@ CourseLit provides a carefully curated selection of professional fonts, organize - **Rubik**: A sans-serif with a geometric feel - **Oswald**: A reworking of the classic style - **Bebas Neue**: A display font with a strong personality -
+
Modern Fonts @@ -272,7 +272,7 @@ CourseLit provides a carefully curated selection of professional fonts, organize - **Lato**: A sans-serif font with a warm feel - **PT Sans**: A font designed for public use - **Quicksand**: A display sans-serif with rounded terminals -
+ Each font is optimized for web use and includes multiple weights for flexibility in design. All fonts support Latin characters and are carefully selected for their readability and professional appearance. @@ -290,7 +290,7 @@ The interactives editor allows you to customize the appearance of interactive el - Shadow effects: From None to 2X Large - Custom styles: Add your own custom styles using [supported Tailwind classes](#supported-tailwind-classes) - Disabled state: How the button looks when it can't be clicked - +
Link @@ -300,7 +300,7 @@ The interactives editor allows you to customize the appearance of interactive el - Text shadow: Add depth to your links - Custom styles: Add your own custom styles using [supported Tailwind classes](#supported-tailwind-classes) - Disabled state: How the link looks when it can't be clicked -
+
Card @@ -309,7 +309,7 @@ The interactives editor allows you to customize the appearance of interactive el - Border style: Choose from various border styles - Shadow effects: Add depth to your cards - Custom styles: Add your own custom styles using [supported Tailwind classes](#supported-tailwind-classes) -
+
Input @@ -320,7 +320,7 @@ The interactives editor allows you to customize the appearance of interactive el - Shadow effects: Add depth to your input fields - Custom styles: Add your own custom styles using [supported Tailwind classes](#supported-tailwind-classes) - Disabled state: How the input looks when it can't be used -
+ ### 4. Structure @@ -332,14 +332,14 @@ The structure editor lets you customize the layout of your pages, like section p Page - Maximum width options: - 2XL (42rem): Compact layout - 3XL (48rem): Standard layout - 4XL (56rem): Wide layout - 5XL (64rem): Extra wide layout - 6XL (72rem): Full width layout - +
Section - Horizontal padding: Space on the left and right sides (None to 9X Large) - Vertical padding: Space on the top and bottom (None to 9X Large) -
+ ## Publishing Changes @@ -387,7 +387,7 @@ When adding custom styles to interactive elements, you can use the following Tai - `text-6xl`: 6X large text - `text-7xl`: 7X large text - `text-8xl`: 8X large text - +
Padding @@ -399,7 +399,7 @@ When adding custom styles to interactive elements, you can use the following Tai #### Horizontal Padding - `px-4` to `px-20`: Horizontal padding from 1rem to 5rem -
+
Colors @@ -454,7 +454,7 @@ Variants available: `hover`, `disabled`, `dark` - `ease-out`: Ease out - `ease-in-out`: Ease in and out - `ease-linear`: Linear -
+
Transforms @@ -481,7 +481,7 @@ Variants available: `hover`, `disabled`, `dark` - `scale-110`: 110% scale - `scale-125`: 125% scale - `scale-150`: 150% scale -
+
Shadows diff --git a/apps/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/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/app/(with-contexts)/(with-layout)/blog/blogs-list.tsx b/apps/web/app/(with-contexts)/(with-layout)/blog/blogs-list.tsx index 9aa869ef6..8e99982d5 100644 --- a/apps/web/app/(with-contexts)/(with-layout)/blog/blogs-list.tsx +++ b/apps/web/app/(with-contexts)/(with-layout)/blog/blogs-list.tsx @@ -2,12 +2,13 @@ import { useContext, useMemo } from "react"; import { BlogContentCard } from "./content-card"; +import { EmptyState } from "./empty-state"; import { PaginationControls } from "@components/public/pagination"; import { Constants, Course } from "@courselit/common-models"; import { useProducts } from "@/hooks/use-products"; import { ProductCardSkeleton } from "@courselit/page-blocks"; import { ThemeContext } from "@components/contexts"; -import { BookOpen } from "lucide-react"; +import { FileText } from "lucide-react"; import { Button, Subheader1 } from "@courselit/page-primitives"; const ITEMS_PER_PAGE = 9; @@ -35,10 +36,14 @@ export function BlogsList({ true, ); + if (!loading && totalPages === 0) { + return ; + } + if (!loading && totalPages && products.length === 0) { return (
- + This page is empty.
@@ -169,11 +180,14 @@ export default function Page() { filterArray, ); - const handleFilterChange = useCallback((value: "all" | CourseType) => { - router.push( - `/dashboard/products?${value !== "all" ? `filter=${value}` : ""}`, - ); - }, []); + const handleFilterChange = useCallback( + (value: "all" | CourseType) => { + router.push( + `/dashboard/products?${value !== "all" ? `filter=${value}` : ""}`, + ); + }, + [router], + ); const handlePageChange = useCallback( (value: number) => { @@ -181,18 +195,9 @@ export default function Page() { `/dashboard/products?page=${value}${filter !== "all" ? `&filter=${filter}` : ""}`, ); }, - [filter], + [filter, router], ); - // if ( - // !checkPermission(profile.permissions!, [ - // permissions.manageAnyCourse, - // permissions.manageCourse, - // ]) - // ) { - // return ; - // } - return ( {totalPages > 0 && (
- {products.map((product: Course) => ( + {products.map((product: DashboardCourse) => ( ; + } + + if ( + !checkPermission(profile.permissions ?? [], [permissions.manageUsers]) + ) { return ; } diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/users/tags/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/users/tags/page.tsx index a3bc39928..478f72168 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/users/tags/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/users/tags/page.tsx @@ -29,7 +29,13 @@ export default function Page() { const address = useContext(AddressContext); const { profile } = useContext(ProfileContext); - if (!checkPermission(profile.permissions!, [permissions.manageUsers])) { + if (!profile) { + return ; + } + + if ( + !checkPermission(profile.permissions ?? [], [permissions.manageUsers]) + ) { return ; } diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/users/users-hub.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/users/users-hub.tsx index 2e56bb9a5..c1c792383 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/users/users-hub.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/users/users-hub.tsx @@ -159,10 +159,7 @@ export default function UsersHub() {
- +
@@ -276,7 +273,7 @@ export default function UsersHub() { { - user.content.filter( + (user.content ?? []).filter( (content) => content.entityType.toLowerCase() === MembershipEntityType.COURSE, @@ -285,7 +282,7 @@ export default function UsersHub() { { - user.content.filter( + (user.content ?? []).filter( (content) => content.entityType.toLowerCase() === MembershipEntityType.COMMUNITY, diff --git a/apps/web/app/(with-contexts)/layout-with-context.tsx b/apps/web/app/(with-contexts)/layout-with-context.tsx index 7233ef713..4c7dc347e 100644 --- a/apps/web/app/(with-contexts)/layout-with-context.tsx +++ b/apps/web/app/(with-contexts)/layout-with-context.tsx @@ -1,6 +1,13 @@ "use client"; -import { ReactNode, useEffect, useState, Suspense } from "react"; +import { + ReactNode, + useEffect, + useState, + Suspense, + useCallback, + startTransition, +} from "react"; import { SiteInfo, ServerConfig } from "@courselit/common-models"; import { AddressContext, @@ -36,26 +43,30 @@ function LayoutContent({ const [theme, setTheme] = useState(initialTheme); const { toast } = useToast(); - useEffect(() => { - if (address && session) { - updateUserProfile(); - } - }, [address, session]); - - async function updateUserProfile() { + const updateUserProfile = useCallback(async () => { try { - const profile = await getUserProfile(address); - if (profile) { - setProfile(profile); + const fetchedProfile = await getUserProfile(address); + if (fetchedProfile) { + setProfile(fetchedProfile); } } catch (err) { + const message = + err instanceof Error ? err.message : "Unknown error occurred"; toast({ title: TOAST_TITLE_ERROR, - description: err.message, + description: message, variant: "destructive", }); } - } + }, [address, toast]); + + useEffect(() => { + if (address && session) { + startTransition(() => { + void updateUserProfile(); + }); + } + }, [address, session, updateUserProfile]); return ( ): media is Media { + return ( + typeof media.mediaId === "string" && + typeof media.originalFileName === "string" && + typeof media.file === "string" + ); +} + async function downloadFile(media: Media, folderPath: string) { const filePath = path.join(folderPath, media.originalFileName); @@ -208,9 +226,11 @@ function createArchive({ async function recordProgress({ courseId, userId, + domainId, }: { courseId: string; userId: string; + domainId: Types.ObjectId; }) { const user: User | null = await UserModel.findOne({ userId }); if (!user) { @@ -229,7 +249,7 @@ async function recordProgress({ await (user as any).save(); await recordActivity({ - domain: user.domain, + domain: domainId, userId: user.userId, type: Constants.ActivityType.DOWNLOADED, entityId: courseId, diff --git a/apps/web/app/api/track/open/route.ts b/apps/web/app/api/track/open/route.ts index 636484212..be4b787f2 100644 --- a/apps/web/app/api/track/open/route.ts +++ b/apps/web/app/api/track/open/route.ts @@ -23,7 +23,12 @@ const pixelBuffer = Buffer.from( "base64", ); -const pixelResponse = new NextResponse(pixelBuffer, { +const pixelArrayBuffer = pixelBuffer.buffer.slice( + pixelBuffer.byteOffset, + pixelBuffer.byteOffset + pixelBuffer.byteLength, +) as ArrayBuffer; + +const pixelResponse = new NextResponse(pixelArrayBuffer, { status: 200, headers: { "Content-Type": "image/png", diff --git a/apps/web/auth.ts b/apps/web/auth.ts index fb8ce5ceb..9ff5c05d4 100644 --- a/apps/web/auth.ts +++ b/apps/web/auth.ts @@ -11,7 +11,9 @@ import { error } from "./services/logger"; import { User } from "next-auth"; import { User as AppUser } from "@courselit/common-models"; -export const { auth, signIn, signOut, handlers } = NextAuth({ +type AuthReturn = ReturnType; + +const authHandlers: AuthReturn = NextAuth({ ...authConfig, providers: [ CredentialsProvider({ @@ -93,3 +95,8 @@ export const { auth, signIn, signOut, handlers } = NextAuth({ }, }, }); + +export const auth: AuthReturn["auth"] = authHandlers.auth; +export const signIn: AuthReturn["signIn"] = authHandlers.signIn; +export const signOut: AuthReturn["signOut"] = authHandlers.signOut; +export const handlers: AuthReturn["handlers"] = authHandlers.handlers; diff --git a/apps/web/components/admin/blogs/editor/layout/header.tsx b/apps/web/components/admin/blogs/editor/layout/header.tsx index 5cceb5680..d2ad078b6 100644 --- a/apps/web/components/admin/blogs/editor/layout/header.tsx +++ b/apps/web/components/admin/blogs/editor/layout/header.tsx @@ -82,7 +82,7 @@ export default function BlogHeader({ id, breadcrumbs }: BlogHeaderProps) { description={DELETE_PRODUCT_POPUP_TEXT} onClick={() => deleteProduct({ - id: course!.id as string, + id: course.courseId as string, backend: address.backend, onDeleteComplete: () => { router.replace(`/dashboard/blogs`); diff --git a/apps/web/components/admin/blogs/editor/layout/index.tsx b/apps/web/components/admin/blogs/editor/layout/index.tsx index 457596b52..3f8721191 100644 --- a/apps/web/components/admin/blogs/editor/layout/index.tsx +++ b/apps/web/components/admin/blogs/editor/layout/index.tsx @@ -19,7 +19,7 @@ export default function BlogEditorLayout({ children, address, }: BlogEditorLayoutProps) { - const course = useCourse(id, address); + const course = useCourse(id); const breadcrumbs = [ { text: "Blogs", url: "/dashboard/blogs" }, { @@ -30,11 +30,7 @@ export default function BlogEditorLayout({ return (
- + {course && children}
); diff --git a/apps/web/components/admin/blogs/editor/publish.tsx b/apps/web/components/admin/blogs/editor/publish.tsx index 3189efb88..d7203dc19 100644 --- a/apps/web/components/admin/blogs/editor/publish.tsx +++ b/apps/web/components/admin/blogs/editor/publish.tsx @@ -128,7 +128,7 @@ export default function Publish({ id }: PublishProps) { variant="soft" disabled={loading} > - {capitalize(privacy)} + {capitalize(privacy ?? "")} diff --git a/apps/web/components/admin/blogs/index.tsx b/apps/web/components/admin/blogs/index.tsx index c92791eac..7ae188082 100644 --- a/apps/web/components/admin/blogs/index.tsx +++ b/apps/web/components/admin/blogs/index.tsx @@ -144,7 +144,6 @@ export default function Blogs() { position={index} onDelete={onDelete} siteinfo={siteinfo as SiteInfo} - address={address} /> ), )} diff --git a/apps/web/components/admin/dashboard-skeleton/nav-user.tsx b/apps/web/components/admin/dashboard-skeleton/nav-user.tsx index 9c3ea8291..febcd9369 100644 --- a/apps/web/components/admin/dashboard-skeleton/nav-user.tsx +++ b/apps/web/components/admin/dashboard-skeleton/nav-user.tsx @@ -20,18 +20,19 @@ import { } from "@/components/ui/sidebar"; import { useContext } from "react"; import { ProfileContext } from "@components/contexts"; -import { useRouter } from "next/navigation"; import Link from "next/link"; export function NavUser() { const { isMobile } = useSidebar(); const { profile: user } = useContext(ProfileContext); + if (!user) { + return null; + } const alias = user.name ?.split(" ") .slice(0, 2) .map((x) => x[0]?.toUpperCase()) .join(""); - const router = useRouter(); return ( diff --git a/apps/web/components/admin/mails/editor-layout.tsx b/apps/web/components/admin/mails/editor-layout.tsx index 7885d450c..aa0038f5a 100644 --- a/apps/web/components/admin/mails/editor-layout.tsx +++ b/apps/web/components/admin/mails/editor-layout.tsx @@ -2,16 +2,18 @@ import { Button } from "@/components/ui/button"; import { CheckCircled, Sync } from "@courselit/icons"; import { BUTTON_SAVING } from "@ui-config/strings"; import { Check, Copy } from "lucide-react"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, startTransition } from "react"; export const EmailEditorLayout = ({ children, isSaving, type = "sequence", + title, }: { children: React.ReactNode; isSaving?: boolean; type?: "sequence" | "product"; + title?: string; }) => { const [showSavedToast, setShowSavedToast] = useState(false); const [prevIsSaving, setPrevIsSaving] = useState(isSaving); @@ -20,7 +22,9 @@ export const EmailEditorLayout = ({ useEffect(() => { // Hide toast when starting a new save operation if (!prevIsSaving && isSaving) { - setShowSavedToast(false); + startTransition(() => { + setShowSavedToast(false); + }); // Clear any existing timer if (timerRef.current) { clearTimeout(timerRef.current); @@ -30,17 +34,23 @@ export const EmailEditorLayout = ({ // Show toast when saving changes from true to false (save completed) if (prevIsSaving && !isSaving) { - setShowSavedToast(true); + startTransition(() => { + setShowSavedToast(true); + }); // Clear any existing timer before setting a new one if (timerRef.current) { clearTimeout(timerRef.current); } timerRef.current = setTimeout(() => { - setShowSavedToast(false); + startTransition(() => { + setShowSavedToast(false); + }); timerRef.current = null; }, 5000); } - setPrevIsSaving(isSaving); + startTransition(() => { + setPrevIsSaving(isSaving); + }); }, [isSaving, prevIsSaving]); // Cleanup timer on unmount @@ -119,8 +129,15 @@ export const EmailEditorLayout = ({ )} -
- {children} +
+ {title ? ( +
+

+ {title} +

+
+ ) : null} +
{children}
diff --git a/apps/web/components/admin/mails/email-viewer.tsx b/apps/web/components/admin/mails/email-viewer.tsx index 9022b5a92..80c85b950 100644 --- a/apps/web/components/admin/mails/email-viewer.tsx +++ b/apps/web/components/admin/mails/email-viewer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, startTransition } from "react"; import { Email, renderEmailToHtml } from "@courselit/email-editor"; import { Edit } from "lucide-react"; import Link from "next/link"; @@ -16,24 +16,32 @@ export default function EmailViewer({ useEffect(() => { if (content) { - setIsLoading(true); - setError(null); + startTransition(() => { + setIsLoading(true); + setError(null); + }); renderEmailToHtml({ email: content, }) .then((html) => { - setRenderedHTML(html); - setIsLoading(false); + startTransition(() => { + setRenderedHTML(html); + setIsLoading(false); + }); }) .catch((err) => { - setError(err.message || "Failed to render email"); - setIsLoading(false); + startTransition(() => { + setError(err.message || "Failed to render email"); + setIsLoading(false); + }); }); } else { - setRenderedHTML(null); - setIsLoading(false); - setError(null); + startTransition(() => { + setRenderedHTML(null); + setIsLoading(false); + setError(null); + }); } }, [content]); diff --git a/apps/web/components/admin/page-editor/index.tsx b/apps/web/components/admin/page-editor/index.tsx index 1e1e44ae9..86b3ec3d2 100644 --- a/apps/web/components/admin/page-editor/index.tsx +++ b/apps/web/components/admin/page-editor/index.tsx @@ -98,7 +98,7 @@ export default function PageEditor({ const [layout, setLayout] = useState[]>([]); const [selectedWidget, setSelectedWidget] = useState(); const [selectedWidgetIndex, setSelectedWidgetIndex] = useState(-1); - const [draftTypefaces, setDraftTypefaces] = useState([]); + const [draftTypefaces, setDraftTypefaces] = useState([]); const [leftPaneContent, setLeftPaneContent] = useState("none"); const [primaryFontFamily, setPrimaryFontFamily] = @@ -167,10 +167,17 @@ export default function PageEditor({ } useEffect(() => { - if (JSON.stringify(layout) !== JSON.stringify(page.draftLayout)) { + if (!page.pageId) { + return; + } + const referenceLayout = page.draftLayout ?? page.layout; + if (!referenceLayout) { + return; + } + if (JSON.stringify(layout) !== JSON.stringify(referenceLayout)) { debouncedSave(page.pageId, layout); } - }, [layout]); + }, [layout, page.pageId, page.draftLayout, page.layout, debouncedSave]); useEffect(() => { if (draftTypefaces.length) { @@ -502,23 +509,40 @@ export default function PageEditor({ setLeftPaneContent("none"); }; - const editWidget = useMemo( - () => - page && - layout?.find((x) => x.widgetId === selectedWidget) && ( - x.widgetId === selectedWidget)} - pageData={page.pageData || {}} - onChange={onWidgetSettingsChanged} - onClose={onClose} - onDelete={deleteWidget} - state={state} - key={selectedWidget} - /> - ), - [selectedWidget], + const selectedWidgetInstance = useMemo( + () => layout.find((x) => x.widgetId === selectedWidget), + [layout, selectedWidget], ); + const hasEditableSelectedWidget = isEditableWidget(selectedWidgetInstance); + + const editWidget = useMemo(() => { + if (!page || !selectedWidget || !hasEditableSelectedWidget) { + return null; + } + + return ( + + ); + }, [ + page, + selectedWidget, + hasEditableSelectedWidget, + selectedWidgetInstance, + onWidgetSettingsChanged, + onClose, + deleteWidget, + state, + ]); + const onAddWidgetBelow = (index: number) => { setSelectedWidgetIndex(index); setLeftPaneContent("widgets"); @@ -532,9 +556,9 @@ export default function PageEditor({ const activeSidePaneContent = ( <> - {leftPaneContent === "widgets" && ( + {leftPaneContent === "widgets" && page.type && ( setLeftPaneContent("none")} /> @@ -570,7 +594,9 @@ export default function PageEditor({ ? page.robotsAllowed : true } - socialImage={page.draftSocialImage || {}} + socialImage={ + page.draftSocialImage ?? page.socialImage ?? null + } onClose={(e) => setLeftPaneContent("none")} onSave={({ title, @@ -580,7 +606,7 @@ export default function PageEditor({ }: { title: string; description: string; - socialImage: Media | {}; + socialImage: Media | null; robotsAllowed: boolean; }) => savePage({ @@ -846,3 +872,14 @@ export default function PageEditor({ ); } + +function isEditableWidget( + widget: Partial | undefined, +): widget is WidgetInstance { + return ( + !!widget && + typeof widget.name === "string" && + typeof widget.widgetId === "string" && + typeof widget.deleteable === "boolean" + ); +} diff --git a/apps/web/components/admin/page-editor/seo-editor.tsx b/apps/web/components/admin/page-editor/seo-editor.tsx index 253a523be..2f266d79a 100644 --- a/apps/web/components/admin/page-editor/seo-editor.tsx +++ b/apps/web/components/admin/page-editor/seo-editor.tsx @@ -29,7 +29,7 @@ export default function SeoEditor({ }: { title: string; description: string; - socialImage: Media | {}; + socialImage: Media | null; robotsAllowed: boolean; profile: Profile; address: Address; @@ -40,7 +40,7 @@ export default function SeoEditor({ const [innerDescription, setInnerDescription] = useState(description); const [innerRobotsAllowed, setInnerRobotsAllowed] = useState(robotsAllowed); const [innerSocialImage, setInnerSocialImage] = - useState>(socialImage); + useState | null>(socialImage ?? null); const onSubmit = (e: FormEvent) => { e.preventDefault(); @@ -48,9 +48,10 @@ export default function SeoEditor({ onSave({ title: name, description: innerDescription, - socialImage: Object.keys(innerSocialImage).length - ? innerSocialImage - : null, + socialImage: + innerSocialImage && Object.keys(innerSocialImage).length + ? (innerSocialImage as Media) + : null, robotsAllowed: innerRobotsAllowed, }); }; @@ -102,12 +103,8 @@ export default function SeoEditor({ { @@ -117,12 +114,12 @@ export default function SeoEditor({ } }} onRemove={() => { - setInnerSocialImage({}); + setInnerSocialImage(null); onSave({ socialImage: null }); }} strings={{}} access="public" - mediaId={innerSocialImage && innerSocialImage.mediaId} + mediaId={innerSocialImage?.mediaId} type="page" /> diff --git a/apps/web/components/admin/page-editor/theme-editor/interactive-selector.tsx b/apps/web/components/admin/page-editor/theme-editor/interactive-selector.tsx index 9c1694085..72cffcfe9 100644 --- a/apps/web/components/admin/page-editor/theme-editor/interactive-selector.tsx +++ b/apps/web/components/admin/page-editor/theme-editor/interactive-selector.tsx @@ -37,6 +37,7 @@ interface InteractiveSelectorProps { type: "button" | "link" | "card" | "input"; theme: ThemeStyle; onChange: (theme: ThemeStyle) => void; + title?: string; } const interactiveDisplayNames: Record = { @@ -50,6 +51,7 @@ function InteractiveSelector({ type, theme, onChange, + title, }: InteractiveSelectorProps) { const value = theme.interactives[type]; @@ -331,49 +333,64 @@ function InteractiveSelector({ ); - const renderBoxShadowConfig = () => ( - - - Shadow - - -
+ const renderBoxShadowConfig = () => { + if (type === "link") { + return null; + } + + const interactiveValue = + (value as ThemeStyle["interactives"][ + | "button" + | "card" + | "input"]) || {}; + + return ( + + + Shadow + +
- - { + onChange({ + ...theme, + interactives: { + ...theme.interactives, + [type]: { + ...interactiveValue, + shadow: newValue, + }, }, - }, - }); - }} - > - - - - - {shadowOptions.map((option) => ( - - {option.label} - - ))} - - + }); + }} + > + + + + + {shadowOptions.map((option) => ( + + {option.label} + + ))} + + +
- -
-
- ); + + + ); + }; const renderHoverInput = () => ( @@ -428,6 +445,7 @@ function InteractiveSelector({ ); case "link": + const linkValue = value as ThemeStyle["interactives"]["link"]; return (
@@ -443,7 +461,7 @@ function InteractiveSelector({
onChange({ @@ -451,7 +469,7 @@ function InteractiveSelector({ interactives: { ...theme.interactives, [type]: { - ...value, + ...linkValue, textShadow: e.target.value, }, @@ -508,6 +526,13 @@ function InteractiveSelector({ return (
+ {title && ( +
+

+ {title} +

+
+ )}
{renderDemo()}
diff --git a/apps/web/components/admin/page-editor/theme-editor/structure-selector.tsx b/apps/web/components/admin/page-editor/theme-editor/structure-selector.tsx index bdf155a92..866d25676 100644 --- a/apps/web/components/admin/page-editor/theme-editor/structure-selector.tsx +++ b/apps/web/components/admin/page-editor/theme-editor/structure-selector.tsx @@ -35,7 +35,7 @@ function StructureSelector({ const renderConfig = () => { if (type === "page") { - const pageValue = value as Theme["structure"]["page"]; + const pageValue = value as ThemeStyle["structure"]["page"]; return (
@@ -74,7 +74,7 @@ function StructureSelector({ } if (type === "section") { - const sectionValue = value as Theme["structure"]["section"]; + const sectionValue = value as ThemeStyle["structure"]["section"]; return (
@@ -90,7 +90,7 @@ function StructureSelector({ ...sectionValue, padding: { ...sectionValue?.padding, - x: newValue as Theme["structure"]["section"]["padding"]["x"], + x: newValue as ThemeStyle["structure"]["section"]["padding"]["x"], }, }, }, @@ -126,7 +126,7 @@ function StructureSelector({ ...sectionValue, padding: { ...sectionValue?.padding, - y: newValue as Theme["structure"]["section"]["padding"]["y"], + y: newValue as ThemeStyle["structure"]["section"]["padding"]["y"], }, }, }, diff --git a/apps/web/components/admin/products/new-customer.tsx b/apps/web/components/admin/products/new-customer.tsx index f658ca7d5..c4a625b17 100644 --- a/apps/web/components/admin/products/new-customer.tsx +++ b/apps/web/components/admin/products/new-customer.tsx @@ -145,7 +145,6 @@ export default function NewCustomer({ courseId }: NewCustomerProps) { JSON.stringify(systemTags) + JSON.stringify(tags) } - side="bottom" options={systemTags} selectedOptions={new Set(tags)} onChange={(values: string[]) => setTags(values)} diff --git a/apps/web/components/admin/products/quiz-builder/index.tsx b/apps/web/components/admin/products/quiz-builder/index.tsx index 669a817b4..894dfc1a9 100644 --- a/apps/web/components/admin/products/quiz-builder/index.tsx +++ b/apps/web/components/admin/products/quiz-builder/index.tsx @@ -1,6 +1,5 @@ -import React from "react"; +import React, { startTransition, useEffect, useState } from "react"; import { Section } from "@courselit/components-library"; -import { useEffect, useState } from "react"; import { LESSON_QUIZ_ADD_QUESTION, LESSON_QUIZ_QUESTION_PLACEHOLDER, @@ -36,10 +35,17 @@ export function QuizBuilder({ content, onChange }: QuizBuilderProps) { ); useEffect(() => { - content.questions && setQuestions(content.questions); - content.passingGrade && setPassingGradePercentage(content.passingGrade); - content.requiresPassingGrade && - setPassingGradeRequired(content.requiresPassingGrade); + startTransition(() => { + if (content.questions) { + setQuestions(content.questions); + } + if (content.passingGrade) { + setPassingGradePercentage(content.passingGrade); + } + if (content.requiresPassingGrade) { + setPassingGradeRequired(content.requiresPassingGrade); + } + }); }, [content]); useEffect(() => { @@ -48,50 +54,95 @@ export function QuizBuilder({ content, onChange }: QuizBuilderProps) { requiresPassingGrade: passingGradeRequired, passingGrade: passingGradePercentage, }); - }, [questions, passingGradeRequired, passingGradePercentage]); + }, [questions, passingGradeRequired, passingGradePercentage, onChange]); - const addNewOption = (index: number) => { - const question = questions[index]; - question.options = [ - ...question.options, - { text: "", correctAnswer: false }, - ]; - setQuestions([...questions]); + const addNewOption = (questionIndex: number) => { + setQuestions((prevQuestions) => + prevQuestions.map((question, index) => + index === questionIndex + ? { + ...question, + options: [ + ...question.options, + { text: "", correctAnswer: false }, + ], + } + : question, + ), + ); }; const setCorrectAnswer = (questionIndex: number) => (index: number, checked: boolean) => { - questions[questionIndex].options[index].correctAnswer = checked; - setQuestions([...questions]); + setQuestions((prevQuestions) => + prevQuestions.map((question, qIdx) => + qIdx === questionIndex + ? { + ...question, + options: question.options.map((option, optIdx) => + optIdx === index + ? { ...option, correctAnswer: checked } + : option, + ), + } + : question, + ), + ); }; const setOptionText = (questionIndex: number) => (index: number, text: string) => { - questions[questionIndex].options[index].text = text; - setQuestions([...questions]); + setQuestions((prevQuestions) => + prevQuestions.map((question, qIdx) => + qIdx === questionIndex + ? { + ...question, + options: question.options.map((option, optIdx) => + optIdx === index + ? { ...option, text } + : option, + ), + } + : question, + ), + ); }; const setQuestionText = (index: number) => (text: string) => { - questions[index].text = text; - setQuestions([...questions]); + setQuestions((prevQuestions) => + prevQuestions.map((question, qIdx) => + qIdx === index ? { ...question, text } : question, + ), + ); }; const removeOption = (questionIndex: number) => (index: number) => { - questions[questionIndex].options.splice(index, 1); - setQuestions([...questions]); + setQuestions((prevQuestions) => + prevQuestions.map((question, qIdx) => + qIdx === questionIndex + ? { + ...question, + options: question.options.filter( + (_, optIdx) => optIdx !== index, + ), + } + : question, + ), + ); }; const deleteQuestion = (questionIndex: number) => { - questions.splice(questionIndex, 1); - setQuestions([...questions]); + setQuestions((prevQuestions) => + prevQuestions.filter((_, idx) => idx !== questionIndex), + ); }; const addNewQuestion = () => - setQuestions([ - ...questions, + setQuestions((prevQuestions) => [ + ...prevQuestions, { text: `${LESSON_QUIZ_QUESTION_PLACEHOLDER} #${ - questions.length + 1 + prevQuestions.length + 1 }`, options: [{ text: "", correctAnswer: false }], }, diff --git a/apps/web/components/admin/products/quiz-builder/question-builder.tsx b/apps/web/components/admin/products/quiz-builder/question-builder.tsx index 30c9ffb57..8250e54ec 100644 --- a/apps/web/components/admin/products/quiz-builder/question-builder.tsx +++ b/apps/web/components/admin/products/quiz-builder/question-builder.tsx @@ -29,6 +29,8 @@ interface QuestionProps { deleteQuestion: (index: number) => void; } +type QuestionOption = Question["options"][number]; + export function QuestionBuilder({ details, index, @@ -104,11 +106,11 @@ export function QuestionBuilder({ {/*

{LESSON_QUIZ_OPTIONS_HEADER}

*/} - {details.options.map((option: Option, index: number) => ( + {details.options.map((option: QuestionOption, index: number) => (
setCorrectOption(index, value) } diff --git a/apps/web/components/admin/settings/index.tsx b/apps/web/components/admin/settings/index.tsx index cd9eaf6f3..d0487214c 100644 --- a/apps/web/components/admin/settings/index.tsx +++ b/apps/web/components/admin/settings/index.tsx @@ -109,8 +109,15 @@ interface SettingsProps { const Settings = (props: SettingsProps) => { const [settings, setSettings] = useState>({}); const [newSettings, setNewSettings] = useState>({}); + + type ApiKeyListItem = { + name: string; + keyId: string; + createdAt?: string | number | Date; + }; + const [apikeyPage, setApikeyPage] = useState(1); - const [apikeys, setApikeys] = useState([]); + const [apikeys, setApikeys] = useState([]); const [loading, setLoading] = useState(false); const selectedTab = [ SITE_SETTINGS_SECTION_GENERAL, @@ -177,7 +184,7 @@ const Settings = (props: SettingsProps) => { setSettingsState(response.settings.settings); } if (response.apikeys) { - setApikeys(response.apikeys); + setApikeys(response.apikeys as ApiKeyListItem[]); } } catch (e) {} }; @@ -367,11 +374,14 @@ const Settings = (props: SettingsProps) => { return; } + const headSnippet = encode(newSettings.codeInjectionHead ?? ""); + const bodySnippet = encode(newSettings.codeInjectionBody ?? ""); + const query = ` mutation { settings: updateSiteInfo(siteData: { - codeInjectionHead: "${encode(newSettings.codeInjectionHead)}", - codeInjectionBody: "${encode(newSettings.codeInjectionBody)}" + codeInjectionHead: "${headSnippet}", + codeInjectionBody: "${bodySnippet}" }) { settings { title, @@ -668,11 +678,7 @@ const Settings = (props: SettingsProps) => { setLoading(true); const fetchRequest = fetch.setPayload(query).build(); await fetchRequest.exec(); - setApikeys( - apikeys.filter( - (item: Record) => item.keyId !== keyId, - ), - ); + setApikeys(apikeys.filter((item) => item.keyId !== keyId)); } catch (e: any) { toast({ title: TOAST_TITLE_ERROR, @@ -745,7 +751,9 @@ const Settings = (props: SettingsProps) => {

{ setNewSettings( Object.assign({}, newSettings, { @@ -1187,15 +1195,12 @@ const Settings = (props: SettingsProps) => { }} > {apikeys.map( - ( - item: Record, - index: number, - ) => ( - + (item: ApiKeyListItem, index: number) => ( +
{item.name} {new Date( - item.createdAt as number, + item.createdAt ?? 0, ).toLocaleDateString()} diff --git a/apps/web/components/admin/users/filter-container/filter-editor-2/email.tsx b/apps/web/components/admin/users/filter-container/filter-editor-2/email.tsx index 68bad057c..7bc9e04dd 100644 --- a/apps/web/components/admin/users/filter-container/filter-editor-2/email.tsx +++ b/apps/web/components/admin/users/filter-container/filter-editor-2/email.tsx @@ -61,7 +61,10 @@ export default function EmailFilterEditor({ onApply }: EmailFilterEditorProps) { name="value" value={value} label="" - onChange={(event: FormEvent) => setValue(event.target.value)} + onChange={(event: FormEvent) => { + const target = event.target as HTMLInputElement; + setValue(target.value); + }} />
void; } export default function FilterEditor({ dismissPopover }: FilterEditorProps) { const [activeCategory, setActiveCategory] = useState(); - const address = useContext(AddressContext); const changeFilter = ( value: Pick | undefined, @@ -67,10 +65,7 @@ export default function FilterEditor({ dismissPopover }: FilterEditorProps) { )} {activeCategory && activeCategory === "community" && ( - + )} {activeCategory && activeCategory === "lastActive" && ( diff --git a/apps/web/components/admin/users/filter-container/filter-editor-2/product.tsx b/apps/web/components/admin/users/filter-container/filter-editor-2/product.tsx index 928662558..db45a596b 100644 --- a/apps/web/components/admin/users/filter-container/filter-editor-2/product.tsx +++ b/apps/web/components/admin/users/filter-container/filter-editor-2/product.tsx @@ -4,6 +4,7 @@ import React, { useCallback, useMemo, useContext, + startTransition, } from "react"; import { FetchBuilder } from "@courselit/utils"; import { @@ -59,16 +60,20 @@ export default function ProductFilterEditor({ try { const response = await fetch.exec(); if (response.courses) { - setProducts([...response.courses]); + startTransition(() => { + setProducts([...response.courses]); + }); } - } catch (err: any) { + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : "Unknown error occurred"; toast({ title: TOAST_TITLE_ERROR, - description: err.message, + description: message, variant: "destructive", }); } - }, [address.backend]); + }, [address.backend, toast]); useEffect(() => { loadCreatorCourses(); diff --git a/apps/web/components/admin/users/filter-container/filter-editor-2/signed-up.tsx b/apps/web/components/admin/users/filter-container/filter-editor-2/signed-up.tsx index c4b159b4a..ec5fbaae5 100644 --- a/apps/web/components/admin/users/filter-container/filter-editor-2/signed-up.tsx +++ b/apps/web/components/admin/users/filter-container/filter-editor-2/signed-up.tsx @@ -69,7 +69,9 @@ export default function SignedUpFilterEditor({ type="date" label={USER_FILTER_DATE_RANGE_DROPDOWN_LABEL} value={value} - onChange={(e: FormEvent) => setValue(e.target.value)} + onChange={(e: FormEvent) => + setValue((e.target as HTMLInputElement).value) + } />
{ + setTags(response.tags); + }); } } catch (err) { + const message = + err instanceof Error ? err.message : "Unknown error occurred"; toast({ title: TOAST_TITLE_ERROR, - description: err.message, + description: message, variant: "destructive", }); } - }, [address.backend]); + }, [address.backend, toast]); useEffect(() => { getTags(); diff --git a/apps/web/components/admin/users/filter-container/index.tsx b/apps/web/components/admin/users/filter-container/index.tsx index 6366aece7..688c58b61 100644 --- a/apps/web/components/admin/users/filter-container/index.tsx +++ b/apps/web/components/admin/users/filter-container/index.tsx @@ -388,7 +388,7 @@ export default function FilterContainer({ count, }); }} - variant="soft" + variant="secondary" className="mx-2" > {USER_FILTER_CLEAR} diff --git a/apps/web/components/community/banner.tsx b/apps/web/components/community/banner.tsx index 208e685b3..1fe060ba7 100644 --- a/apps/web/components/community/banner.tsx +++ b/apps/web/components/community/banner.tsx @@ -11,11 +11,12 @@ import { import { isTextEditorNonEmpty } from "@ui-lib/utils"; import { BUTTON_SAVING, TOAST_TITLE_SUCCESS } from "@ui-config/strings"; import { AddressContext } from "@components/contexts"; +import type { TextEditorContent } from "@courselit/common-models"; interface BannerComponentProps { canEdit: boolean; - initialBannerText: Record; - onSaveBanner: (text: Record) => Promise; + initialBannerText?: TextEditorContent; + onSaveBanner: (text: TextEditorContent) => Promise; } export default function Banner({ @@ -23,11 +24,14 @@ export default function Banner({ initialBannerText, onSaveBanner, }: BannerComponentProps) { - const [bannerText, setBannerText] = useState( - initialBannerText || TextEditorEmptyDoc, - ); + const initialContent: TextEditorContent = + initialBannerText || + (TextEditorEmptyDoc as unknown as TextEditorContent); + const [bannerText, setBannerText] = + useState(initialContent); const [isEditing, setIsEditing] = useState(false); - const [editedBannerText, setEditedBannerText] = useState(bannerText); + const [editedBannerText, setEditedBannerText] = + useState(initialContent); const [isSaving, setIsSaving] = useState(false); const textareaRef = useRef(null); const address = useContext(AddressContext); @@ -61,7 +65,12 @@ export default function Banner({ setIsEditing(false); }; - if (!canEdit && !isTextEditorNonEmpty(initialBannerText)) { + const hasExistingBanner = isTextEditorNonEmpty( + initialBannerText || + (TextEditorEmptyDoc as unknown as TextEditorContent), + ); + + if (!canEdit && !hasExistingBanner) { return null; } @@ -72,7 +81,14 @@ export default function Banner({ <> {isTextEditorNonEmpty(bannerText) ? ( - + + } + /> ) : ( canEdit && (
diff --git a/apps/web/components/community/comment-section.tsx b/apps/web/components/community/comment-section.tsx index 3073d9e8d..c53f71510 100644 --- a/apps/web/components/community/comment-section.tsx +++ b/apps/web/components/community/comment-section.tsx @@ -616,7 +616,7 @@ export default function CommentSection({ ))}
- {!profile.name && ( + {!profile?.name && (
Complete your{" "} @@ -625,7 +625,7 @@ export default function CommentSection({ to join this community or post here
)} - {profile.name && ( + {profile?.name && (