Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/usr/bin/env sh
. "$(dirname "$0")/_/husky.sh"

pnpm exec lint-staged
npx lint-staged
2 changes: 1 addition & 1 deletion apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
98 changes: 84 additions & 14 deletions apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { ThemeContext } from "@components/contexts";
import { ServerConfigContext, ThemeContext } from "@components/contexts";
import {
Button,
Caption,
Expand Down Expand Up @@ -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);
Expand All @@ -42,26 +43,98 @@ 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) {
setShowCode(true);
} 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);
}
Expand All @@ -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 {
Expand All @@ -99,7 +167,8 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
{error && (
<div
style={{
color: theme?.theme?.colors?.error,
color: theme?.theme?.colors?.light
?.destructive,
}}
className="flex items-center gap-2 mb-4"
>
Expand Down Expand Up @@ -218,6 +287,7 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
</div>
</div>
</div>
<RecaptchaScriptLoader />
</Section>
);
}
Original file line number Diff line number Diff line change
@@ -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<EmailTemplate[]>([]);
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<AppState, null, AnyAction>)(
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<AppState, null, AnyAction>)(
networkAction(false),
);
}
};

const onTemplateClick = (template: EmailTemplate) => {
createSequence(template);
};

return (
<div className="p-8">
<h1 className="text-4xl font-semibold mb-8">Choose a template</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{[...systemTemplates, ...templates].map((template) => (
<Card
key={template.templateId}
className="cursor-pointer hover:shadow-lg transition-shadow"
onClick={() => onTemplateClick(template)}
>
<CardHeader>
<CardTitle>{template.title}</CardTitle>
</CardHeader>
<CardContent>
<div className="h-48 bg-gray-200 flex items-center justify-center">
<p className="text-gray-500">Preview</p>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
};

export default NewMailPageClient;
26 changes: 26 additions & 0 deletions apps/web/app/(with-contexts)/dashboard/mails/new/page.tsx
Original file line number Diff line number Diff line change
@@ -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<EmailTemplate[]> {
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 <NewMailPageClient systemTemplates={systemTemplates} />;
}
Loading
Loading