Skip to content
Draft
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
32 changes: 28 additions & 4 deletions js/src/app/router/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,27 @@ import { RequireAuth } from "@/app/router/guards/RequireAuth";
import AdminPage from "@/features/admin/Admin.page";
import AdminLoginPage from "@/features/admin/AdminLogin.page";
import EmailAdminPage from "@/features/emails/EmailAdminPage";
import { EmailHistoryDetailPage } from "@/features/emails/EmailHistoryDetailPage";
import { EmailHistoryPage } from "@/features/emails/EmailHistoryPage";
import { EmailProgressFallbackPage } from "@/features/emails/EmailProgressFallbackPage";
import { EmailProgressPage } from "@/features/emails/EmailProgressPage";
import { EmailSendPage } from "@/features/emails/EmailSendPage";
import HomePage from "@/features/home/Home.page";
import SamplePage from "@/features/sample/Sample.page";
import SampleAdminPage from "@/features/sample/SampleAdmin.page";
import { SignUpPage } from "@/features/sign-up/SignUp.page";
import { createBrowserRouter } from "react-router-dom";
import { Navigate, createBrowserRouter } from "react-router-dom";

function createEmailAdminTabRoutes() {
return [
{ index: true, element: <Navigate to="send" replace /> },
{ path: "send", element: <EmailSendPage /> },
{ path: "progress", element: <EmailProgressFallbackPage /> },
{ path: "progress/:requestId", element: <EmailProgressPage /> },
{ path: "history", element: <EmailHistoryPage /> },
{ path: "history/:requestId", element: <EmailHistoryDetailPage /> },
];
}

export const router = createBrowserRouter([
// Public admin login: its page owns the full viewport.
Expand All @@ -24,8 +40,12 @@ export const router = createBrowserRouter([
{ path: "sign-up", element: <SignUpPage /> },
],
},
// Temporary public email admin page for TESTING
{ path: "email", element: <EmailAdminPage /> },
// Temporary public email routes for TESTING (before auth is wired)
{
path: "email",
element: <EmailAdminPage />,
children: createEmailAdminTabRoutes(),
},
// Authenticated: guard -> layout -> page. Admin nests a second guard + layout.
{
element: <RequireAuth />,
Expand All @@ -41,7 +61,11 @@ export const router = createBrowserRouter([
element: <AdminLayout />,
children: [
{ path: "admin", element: <AdminPage /> },
{ path: "admin/email", element: <EmailAdminPage /> },
{
path: "admin/email",
element: <EmailAdminPage />,
children: createEmailAdminTabRoutes(),
},
{ path: "sample/admin", element: <SampleAdminPage /> },
],
},
Expand Down
62 changes: 37 additions & 25 deletions js/src/features/emails/EmailAdminPage.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,42 @@
import { CsvUploader } from "@/features/emails/_components/CsvUploader";
import { EmailPreviewer } from "@/features/emails/_components/EmailPreviewer";
import { EmailSender } from "@/features/emails/_components/EmailSender";
import {
SendAsyncRequest,
type MessagePreview,
} from "@/features/emails/dto/emailDto";
import { Box, Flex, Stack } from "@mantine/core";
import { useState } from "react";

import { Box, Tabs } from "@mantine/core";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
type EmailAdminTabValue = "send" | "progress" | "history";
function getSelectedTab(pathname: string): EmailAdminTabValue {
if (pathname.includes("/history")) {
return "history";
}
if (pathname.includes("/progress")) {
return "progress";
}
return "send";
}
export default function EmailAdminPage() {
const [request, setRequest] = useState<SendAsyncRequest | null>(null);
const [previews, setPreviews] = useState<MessagePreview[] | null>(null);

const location = useLocation();
const navigate = useNavigate();
const selectedTab = getSelectedTab(location.pathname);
const handleTabChange = (value: string | null) => {
if (value === "send") {
navigate("send");
return;
}
if (value === "progress") {
navigate("progress");
return;
}
if (value === "history") {
navigate("history");
}
};
return (
<Flex align="flex-start" gap="lg" wrap="nowrap">
<Stack w="30%" gap="lg">
<CsvUploader setRequest={setRequest} />
<EmailSender request={request} />
</Stack>
<Box w="70%">
<EmailPreviewer
previews={previews}
setPreviews={setPreviews}
request={request}
/>
<Tabs value={selectedTab} onChange={handleTabChange}>
<Tabs.List>
<Tabs.Tab value="send">Send Emails</Tabs.Tab>
<Tabs.Tab value="progress">Live Progress</Tabs.Tab>
<Tabs.Tab value="history">History</Tabs.Tab>
</Tabs.List>
<Box pt="lg">
<Outlet />
</Box>
</Flex>
</Tabs>
);
}
14 changes: 14 additions & 0 deletions js/src/features/emails/EmailHistoryDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { EmailHistory } from "@/features/emails/_components/EmailHistory";
import { useNavigate, useParams } from "react-router-dom";

export function EmailHistoryDetailPage() {
const { requestId } = useParams<{ requestId: string }>();
const navigate = useNavigate();

return (
<EmailHistory
detailRequestId={requestId ?? null}
onBack={() => navigate("../history")}
/>
);
}
8 changes: 8 additions & 0 deletions js/src/features/emails/EmailHistoryPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { EmailHistory } from "@/features/emails/_components/EmailHistory";
import { useNavigate } from "react-router-dom";

export function EmailHistoryPage() {
const navigate = useNavigate();

return <EmailHistory onSelectRequest={(requestId) => navigate(requestId)} />;
}
9 changes: 9 additions & 0 deletions js/src/features/emails/EmailProgressFallbackPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Text } from "@mantine/core";

export function EmailProgressFallbackPage() {
return (
<Text c="dimmed">
No Email Sending Session in progress. Please select a batch from history
</Text>
);
}
24 changes: 24 additions & 0 deletions js/src/features/emails/EmailProgressPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { EmailProgress } from "@/features/emails/_components/EmailProgress";
import { Stack, Group, Text, Button } from "@mantine/core";
import { useNavigate, useParams } from "react-router-dom";

export function EmailProgressPage() {
const { requestId } = useParams<{ requestId: string }>();
const navigate = useNavigate();

if (!requestId) {
return <Text c="dimmed">Missing request ID.</Text>;
}

return (
<Stack gap="lg">
<Group justify="space-between">
<Text fw={600}>Batch {requestId}</Text>
<Button variant="subtle" onClick={() => navigate("../send")} size="xs">
New Send
</Button>
</Group>
<EmailProgress requestId={requestId} />
</Stack>
);
}
59 changes: 59 additions & 0 deletions js/src/features/emails/EmailSendPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type {
MessagePreview,
SendAsyncRequest,
} from "@/features/emails/dto/emailDto";

import { CsvUploader } from "@/features/emails/_components/CsvUploader";
import { EmailPreviewer } from "@/features/emails/_components/EmailPreviewer";
import { EmailSender } from "@/features/emails/_components/EmailSender";
import { TemplateSelector } from "@/features/emails/_components/TemplateSelector";
import { Box, Flex, Stack } from "@mantine/core";
import { useState } from "react";
import { useNavigate } from "react-router-dom";

export function EmailSendPage() {
const [request, setRequest] = useState<SendAsyncRequest | null>(null);
const [previews, setPreviews] = useState<MessagePreview[] | null>(null);
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(
null,
);
const [isSending, setIsSending] = useState(false);
const navigate = useNavigate();

const handleTemplateChange = (templateId: string | null) => {
setSelectedTemplateId(templateId);
setRequest(null);
setPreviews(null);
};

return (
<Flex align="flex-start" gap="lg" wrap="nowrap">
<Stack w="30%" gap="lg">
{/* Template selector - new for async */}
<TemplateSelector
value={selectedTemplateId}
onChange={handleTemplateChange}
label="Email Template"
/>
{/* CSV uploader */}
<CsvUploader templateId={selectedTemplateId} setRequest={setRequest} />
{/* Send button */}
<EmailSender
request={request}
selectedTemplateId={selectedTemplateId}
isSending={isSending}
setIsSending={setIsSending}
navigate={navigate}
/>
</Stack>
<Box w="70%">
{/* Preview */}
<EmailPreviewer
previews={previews}
setPreviews={setPreviews}
request={request}
/>
</Box>
</Flex>
);
}
70 changes: 7 additions & 63 deletions js/src/features/emails/_components/CsvUploader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@ import type {
} from "@/features/emails/dto/emailDto";

import { showEmailError } from "@/features/emails/api/emailError";
import {
EmailTemplate,
emailTemplateMap,
} from "@/features/emails/api/emailTemplate";
import {
dataToSendRequest,
parseUserFile,
Expand All @@ -17,13 +13,11 @@ import {
import {
FileInput,
Button,
NativeSelect,
Flex,
Box,
ScrollArea,
Text,
Table,
Spoiler,
} from "@mantine/core";
import { useEffect, useState } from "react";

Expand All @@ -37,31 +31,32 @@ const rowStyle = {

/**
* Main component for user and pairing CSV file uploaders, an email template dropdown, and email generation button.
* @param setRequest - A function to set the SendRequest object in the parent component.
* @returns A component that allows users to upload CSV files, select an email template, and generate a SendRequest object.
* @param setRequest - A function to set the SendAsyncRequest object in the parent component.
* @returns A component that allows users to upload CSV files, select an email template, and generate a SendAsyncRequest object.
*/
export function CsvUploader({
templateId,
setRequest,
}: {
templateId: string | null;
setRequest: React.Dispatch<React.SetStateAction<SendAsyncRequest | null>>;
}) {
const [userMap, setUserMap] = useState<Map<string, User>>(new Map());
const [pairList, setPairList] = useState<Pair[]>([]);
const [userFile, setUserFile] = useState<File | null>(null);
const [pairingFile, setPairingFile] = useState<File | null>(null);
const [template, setTemplate] = useState("");

const handleCSV = async () => {
if (!userMap || userMap.size === 0) {
showEmailError("Missing file", "Please upload a User CSV.");
return;
}
if (template === "") {
if (!templateId) {
showEmailError("Missing template", "Please select a template.");
return;
}
try {
const req = await dataToSendRequest(userMap, pairList, template);
const req = await dataToSendRequest(userMap, pairList, templateId);
setRequest(req);
} catch (err) {
showEmailError("Error when processing CSV", `${err}`);
Expand All @@ -80,10 +75,7 @@ export function CsvUploader({
setPairFile={setPairingFile}
setPairs={setPairList}
/>
<TemplateSelect selection={template} setSelection={setTemplate} />
<Button onClick={() => void handleCSV()}>
Process Files and Template
</Button>
<Button onClick={() => void handleCSV()}>Process Files</Button>
</Flex>
);
}
Expand Down Expand Up @@ -260,51 +252,3 @@ export function PairingCsvUpload({
</Box>
);
}

/**
* Component for email template selection.
* @param selection - The currently selected email template. (undefined if no template is selected)
* @param setSelection - A function to handle changes to the selected email template.
* @returns Email template selection dropdown and template text preview.
*/
export function TemplateSelect({
selection,
setSelection,
}: {
selection: string | undefined;
setSelection: React.Dispatch<React.SetStateAction<string>>;
}) {
const [template, setTemplate] = useState<EmailTemplate>();

useEffect(() => {
if (selection) {
// Display the appropriate template
const template = emailTemplateMap[selection];
setTemplate(template);
} else {
setTemplate(undefined);
}
}, [selection]);

return (
<Flex gap="xxs" direction="column">
<NativeSelect
value={selection}
onChange={(event) => setSelection(event.currentTarget.value)}
label="Email Templates"
description="Select an email template"
data={[{ label: "Select a template", value: "" }, "Pair", "Reminder"]}
/>
<Flex>
<Spoiler maxHeight={0} showLabel="Preview Template" hideLabel="Hide">
<Text size="xs">Subject: {template ? template.subject : ""}</Text>
<br />
<Text size="xs" style={{ whiteSpace: "pre-line" }}>
Body:{" "}
{template ? template.body : "Select a template to see its content."}
</Text>
</Spoiler>
</Flex>
</Flex>
);
}
Loading