diff --git a/.husky/pre-commit b/.husky/pre-commit index 770b781fb..25d223573 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/usr/bin/env sh . "$(dirname "$0")/_/husky.sh" -pnpm exec lint-staged \ No newline at end of file +npx lint-staged \ No newline at end of file diff --git a/apps/docs/package.json b/apps/docs/package.json index a9d20749c..3c6d0e15d 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -18,7 +18,7 @@ "@docsearch/css": "^3.1.0", "@docsearch/react": "^3.1.0", "@types/node": "^18.0.0", - "@types/react": "^17.0.45", + "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", "astro": "^1.4.2", "preact": "^10.7.3", diff --git a/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx b/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx index 119224591..fad10c976 100644 --- a/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx +++ b/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { ThemeContext } from "@components/contexts"; +import { ServerConfigContext, ThemeContext } from "@components/contexts"; import { Button, Caption, @@ -32,7 +32,8 @@ import { } from "@/ui-config/strings"; import Link from "next/link"; import { TriangleAlert } from "lucide-react"; -import { useRouter } from "next/navigation"; +import { useRecaptcha } from "@/hooks/use-recaptcha"; +import RecaptchaScriptLoader from "@/components/recaptcha-script-loader"; export default function LoginForm({ redirectTo }: { redirectTo?: string }) { const { theme } = useContext(ThemeContext); @@ -42,15 +43,80 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) { const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const { toast } = useToast(); - const router = useRouter(); + const serverConfig = useContext(ServerConfigContext); + const { executeRecaptcha } = useRecaptcha(); const requestCode = async function (e: FormEvent) { e.preventDefault(); - const url = `/api/auth/code/generate?email=${encodeURIComponent( - email, - )}`; + setLoading(true); + setError(""); + + if (serverConfig.recaptchaSiteKey) { + if (!executeRecaptcha) { + toast({ + title: TOAST_TITLE_ERROR, + description: + "reCAPTCHA service not available. Please try again later.", + variant: "destructive", + }); + setLoading(false); + return; + } + + const recaptchaToken = await executeRecaptcha("login_code_request"); + if (!recaptchaToken) { + toast({ + title: TOAST_TITLE_ERROR, + description: + "reCAPTCHA validation failed. Please try again.", + variant: "destructive", + }); + setLoading(false); + return; + } + try { + const recaptchaVerificationResponse = await fetch( + "/api/recaptcha", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: recaptchaToken }), + }, + ); + + const recaptchaData = + await recaptchaVerificationResponse.json(); + + if ( + !recaptchaVerificationResponse.ok || + !recaptchaData.success || + (recaptchaData.score && recaptchaData.score < 0.5) + ) { + toast({ + title: TOAST_TITLE_ERROR, + description: `reCAPTCHA verification failed. ${recaptchaData.score ? `Score: ${recaptchaData.score.toFixed(2)}.` : ""} Please try again.`, + variant: "destructive", + }); + setLoading(false); + return; + } + } catch (err) { + console.error("Error during reCAPTCHA verification:", err); + toast({ + title: TOAST_TITLE_ERROR, + description: + "reCAPTCHA verification failed. Please try again.", + variant: "destructive", + }); + setLoading(false); + return; + } + } + try { - setLoading(true); + const url = `/api/auth/code/generate?email=${encodeURIComponent( + email, + )}`; const response = await fetch(url); const resp = await response.json(); if (response.ok) { @@ -58,10 +124,17 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) { } else { toast({ title: TOAST_TITLE_ERROR, - description: resp.error, + description: resp.error || "Failed to request code.", variant: "destructive", }); } + } catch (err) { + console.error("Error during requestCode:", err); + toast({ + title: TOAST_TITLE_ERROR, + description: "An unexpected error occurred. Please try again.", + variant: "destructive", + }); } finally { setLoading(false); } @@ -79,11 +152,6 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) { if (response?.error) { setError(`Can't sign you in at this time`); } else { - // toast({ - // title: TOAST_TITLE_SUCCESS, - // description: LOGIN_SUCCESS, - // }); - // router.replace(redirectTo || "/dashboard/my-content"); window.location.href = redirectTo || "/dashboard/my-content"; } } finally { @@ -99,7 +167,8 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) { {error && (
@@ -218,6 +287,7 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
+ ); } diff --git a/apps/web/app/(with-contexts)/dashboard/mails/new/new-mail-page-client.tsx b/apps/web/app/(with-contexts)/dashboard/mails/new/new-mail-page-client.tsx new file mode 100644 index 000000000..ffda0f4d7 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/mails/new/new-mail-page-client.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { + Address, + EmailTemplate, + SequenceType, +} from "@courselit/common-models"; +import { useToast } from "@courselit/components-library"; +import { AppDispatch, AppState } from "@courselit/state-management"; +import { networkAction } from "@courselit/state-management/dist/action-creators"; +import { FetchBuilder } from "@courselit/utils"; +import { + TOAST_TITLE_ERROR, +} from "@ui-config/strings"; +import { useEffect, useState } from "react"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { useRouter, useSearchParams } from "next/navigation"; +import { ThunkDispatch } from "redux-thunk"; +import { AnyAction } from "redux"; +import { AddressContext } from "@components/contexts"; +import { useContext } from "react"; + +interface NewMailPageClientProps { + systemTemplates: EmailTemplate[]; +} + +const NewMailPageClient = ({ systemTemplates }: NewMailPageClientProps) => { + const address = useContext(AddressContext); + const [templates, setTemplates] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const { toast } = useToast(); + const router = useRouter(); + const searchParams = useSearchParams(); + const dispatch = () => {}; + + const type = searchParams?.get("type") as SequenceType; + + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setIsGraphQLEndpoint(true); + + useEffect(() => { + loadTemplates(); + }, []); + + const loadTemplates = async () => { + setIsLoading(true); + const query = ` + query GetEmailTemplates { + templates: getEmailTemplates { + templateId + title + content { + content { + blockType + settings + } + style + meta + } + } + }`; + + const fetcher = fetch + .setPayload({ + query, + }) + .build(); + + try { + dispatch && dispatch(networkAction(true)); + const response = await fetcher.exec(); + if (response.templates) { + setTemplates(response.templates); + } + } catch (e: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: e.message, + variant: "destructive", + }); + } finally { + dispatch && dispatch(networkAction(false)); + setIsLoading(false); + } + }; + + const createSequence = async (template: EmailTemplate) => { + const mutation = ` + mutation createSequence( + $type: SequenceType!, + $title: String!, + $content: String! + ) { + sequence: createSequence(type: $type, title: $title, content: $content) { + sequenceId + } + } + `; + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setPayload({ + query: mutation, + variables: { + type: type.toUpperCase(), + title: template.title, + content: JSON.stringify(template.content), + }, + }) + .setIsGraphQLEndpoint(true) + .build(); + try { + dispatch && + (dispatch as ThunkDispatch)( + networkAction(true), + ); + const response = await fetch.exec(); + if (response.sequence && response.sequence.sequenceId) { + router.push( + `/dashboard/mails/${type}/${response.sequence.sequenceId}`, + ); + } + } catch (err) { + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } finally { + dispatch && + (dispatch as ThunkDispatch)( + networkAction(false), + ); + } + }; + + const onTemplateClick = (template: EmailTemplate) => { + createSequence(template); + }; + + return ( +
+

Choose a template

+
+ {[...systemTemplates, ...templates].map((template) => ( + onTemplateClick(template)} + > + + {template.title} + + +
+

Preview

+
+
+
+ ))} +
+
+ ); +}; + +export default NewMailPageClient; diff --git a/apps/web/app/(with-contexts)/dashboard/mails/new/page.tsx b/apps/web/app/(with-contexts)/dashboard/mails/new/page.tsx new file mode 100644 index 000000000..0d721bbed --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/mails/new/page.tsx @@ -0,0 +1,26 @@ +import { promises as fs } from "fs"; +import path from "path"; +import { EmailTemplate } from "@courselit/common-models"; +import NewMailPageClient from "./new-mail-page-client"; + +async function getSystemTemplates(): Promise { + const templatesDir = path.join( + process.cwd(), + "apps/web/templates/system-emails", + ); + const filenames = await fs.readdir(templatesDir); + + const templates = filenames.map(async (filename) => { + const filePath = path.join(templatesDir, filename); + const fileContents = await fs.readFile(filePath, "utf8"); + return JSON.parse(fileContents); + }); + + return Promise.all(templates); +} + +export default async function NewMailPage() { + const systemTemplates = await getSystemTemplates(); + + return ; +} diff --git a/apps/web/app/(with-contexts)/dashboard/mails/template/[id]/internal/page.tsx b/apps/web/app/(with-contexts)/dashboard/mails/template/[id]/internal/page.tsx new file mode 100644 index 000000000..408f832dd --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/mails/template/[id]/internal/page.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { EmailEditor } from "@courselit/email-editor"; +import "@courselit/email-editor/styles.css"; +import { TOAST_TITLE_ERROR } from "@ui-config/strings"; +import { useState, useEffect, useCallback, useRef, useMemo } from "react"; +import type { Email as EmailContent } from "@courselit/email-editor"; +import { useToast } from "@courselit/components-library"; +import { debounce } from "@courselit/utils"; +import { EmailEditorLayout } from "@components/admin/mails/editor-layout"; +import { useGraphQLFetch } from "@/hooks/use-graphql-fetch"; +import { EmailTemplate } from "@courselit/common-models"; + +export default function EmailTemplateEditorPage({ + params, +}: { + params: { + id: string; + }; +}) { + const [email, setEmail] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const { toast } = useToast(); + const [template, setTemplate] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const initialValues = useRef({ + content: null as EmailContent | null, + }); + const isInitialLoad = useRef(true); + + const fetch = useGraphQLFetch(); + + const loadTemplate = useCallback(async () => { + setLoading(true); + const query = ` + query GetEmailTemplate($templateId: String!) { + template: getEmailTemplate(templateId: $templateId) { + templateId + title + content { + content { + blockType + settings + } + style + meta + } + } + } + `; + try { + const response = await fetch + .setPayload({ + query, + variables: { + templateId: params.id, + }, + }) + .build() + .exec(); + if (response.template) { + setTemplate(response.template); + initialValues.current = { + content: response.template.content, + }; + setEmail(response.template.content); + isInitialLoad.current = false; + } + } catch (e: any) { + setError(e.message); + } finally { + setLoading(false); + } + }, [params.id, fetch]); + + useEffect(() => { + loadTemplate(); + }, [loadTemplate]); + + const saveEmail = useCallback( + async (emailContent: EmailContent) => { + const hasChanged = + JSON.stringify(emailContent) !== + JSON.stringify(initialValues.current.content); + + if (!hasChanged) { + return; + } + + setIsSaving(true); + + const mutation = ` + mutation UpdateEmailTemplate( + $templateId: String!, + $content: String, + ) { + template: updateEmailTemplate( + templateId: $templateId, + content: $content, + ) { + templateId + title + content { + content { + blockType + settings + } + style + meta + } + } + }`; + + const fetcher = fetch + .setPayload({ + query: mutation, + variables: { + templateId: params.id, + content: JSON.stringify(emailContent), + }, + }) + .build(); + + try { + await fetcher.exec(); + + initialValues.current = { + content: emailContent, + }; + } catch (e: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: e.message, + variant: "destructive", + }); + } finally { + setIsSaving(false); + } + }, + [params.id, fetch, toast], + ); + + const debouncedSave = useMemo(() => debounce(saveEmail, 1000), [saveEmail]); + + const handleEmailChange = (newEmailContent: EmailContent) => { + debouncedSave(newEmailContent); + }; + + const title = template?.title || "Untitled Template"; + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + + + ); + } + + return ( + + {email && ( + + )} + + ); +} + +const LoadingState = () => ( +
+
Loading template editor...
+
+); + +const ErrorState = ({ error }: { error: string }) => ( +
+
Failed to load template: {error}
+
+); diff --git a/apps/web/app/(with-contexts)/dashboard/mails/template/[id]/page.tsx b/apps/web/app/(with-contexts)/dashboard/mails/template/[id]/page.tsx new file mode 100644 index 000000000..0b59fd5a3 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/mails/template/[id]/page.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { Button2 } from "@courselit/components-library"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { LogOut } from "lucide-react"; +import Link from "next/link"; + +export default function EmailTemplateEditorPage({ + params, +}: { + params: { + id: string; + }; +}) { + const searchParams = useSearchParams(); + const redirectTo = searchParams?.get("redirectTo"); + + return ( + + ); +} +const EditorLayout = ({ + src, + redirectTo, +}: { + src: string; + redirectTo: string; +}) => { + return ( +
+
+
+
+
+
+ Template Editor +
+
+
+
+ + + + + + + + + + +

Exit

+
+
+
+
+
+
+
+