added a feature for sending mail from within the application

This commit is contained in:
2025-12-12 22:39:59 -05:00
parent 4e74a38287
commit e200667611
10 changed files with 480 additions and 144 deletions
+1
View File
@@ -1,5 +1,6 @@
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080 NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
BACKEND_BASE_URL=http://localhost:8080
# Middleware to limited site to only root page # Middleware to limited site to only root page
NEXT_PUBLIC_LAUNCH_ONLY_ROOT=false NEXT_PUBLIC_LAUNCH_ONLY_ROOT=false
+57
View File
@@ -0,0 +1,57 @@
"use server";
export type SendEmailPayload = {
recipient: string;
subject: string;
body: string;
};
export type ApiResponse<T> = {
success?: boolean;
message?: string;
data?: T;
};
export type EmailRequest = {
id: number;
recipient: string;
subject: string;
body: string;
sentAt: string | null;
status: "PENDING" | "SENT" | "FAILED" | string;
errorMessage: string | null;
createdAt: string;
};
export async function sendEmailAction(
payload: SendEmailPayload
): Promise<ApiResponse<EmailRequest>> {
const baseUrl = process.env.BACKEND_BASE_URL;
if (!baseUrl) {
throw new Error("BACKEND_BASE_URL is not set");
}
const res = await fetch(`${baseUrl}/api/email/send`, {
method: "POST",
headers: {
"Content-Type": "application/json",
// Authorization: `Bearer ${process.env.BACKEND_JWT}`, // if needed
},
body: JSON.stringify(payload),
cache: "no-store",
});
const data: ApiResponse<EmailRequest> | null = await res
.json()
.catch(() => null);
if (!res.ok) {
throw new Error(data?.message ?? `Request failed with ${res.status}`);
}
if (!data) {
throw new Error("Empty response body");
}
return data;
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { sendEmailAction, type ApiResponse, type EmailRequest } from "/app/actions/sendEmail";
import { Button, Field, Input, Textarea } from "@/components/ui/form";
export default function SendEmailForm(): JSX.Element {
const [result, setResult] = useState<
ApiResponse<EmailRequest> | { error: string } | null
>(null);
const [submitting, setSubmitting] = useState(false);
const [showSuccess, setShowSuccess] = useState(false);
const successTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (successTimerRef.current) clearTimeout(successTimerRef.current);
};
}, []);
async function onSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
e.preventDefault();
setResult(null);
setShowSuccess(false);
if (successTimerRef.current) clearTimeout(successTimerRef.current);
const form = new FormData(e.currentTarget);
const recipient = String(form.get("recipient") ?? "");
const subject = String(form.get("subject") ?? "");
const body = String(form.get("body") ?? "");
try {
setSubmitting(true);
const data = await sendEmailAction({ recipient, subject, body });
setResult(data);
if (data?.success === true) {
setShowSuccess(true);
successTimerRef.current = setTimeout(() => {
setShowSuccess(false);
}, 2000);
}
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
setResult({ error: message });
} finally {
setSubmitting(false);
}
}
return (
<div className="mx-auto w-full max-w-xl px-4 py-10 text-zinc-50">
<h1 className="text-2xl font-semibold tracking-tight">
Send <span className="text-amber-300">Email</span>
</h1>
<p className="mt-2 text-sm text-zinc-400">
Compose a message and send it from the admin panel.
</p>
<form onSubmit={onSubmit} className="mt-6 space-y-4">
{result && "error" in result && (
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{result.error}
</div>
)}
<Field label="Recipient" htmlFor="recipient">
<Input
id="recipient"
name="recipient"
type="email"
placeholder="user@example.com"
defaultValue="user@example.com"
required
/>
</Field>
<Field label="Subject" htmlFor="subject">
<Input
id="subject"
name="subject"
placeholder="Test subject"
defaultValue="Test subject"
required
/>
</Field>
<Field label="Body" htmlFor="body">
<Textarea
id="body"
name="body"
placeholder="Write your message…"
defaultValue="Test body"
rows={6}
required
/>
</Field>
<Button type="submit" disabled={submitting}>
{submitting ? "Sending…" : showSuccess ? "Success" : "Send"}
</Button>
<div className="rounded-md border border-zinc-800 bg-zinc-950/60 p-3">
<div className="text-xs font-medium text-zinc-400">Response</div>
<pre className="mt-2 overflow-x-auto text-xs text-zinc-200">
{result ? JSON.stringify(result, null, 2) : "No response yet."}
</pre>
</div>
</form>
</div>
);
}
+6 -1
View File
@@ -49,10 +49,15 @@ const navItems = [
icon: <Settings className="h-4 w-4" />, icon: <Settings className="h-4 w-4" />,
}, },
{ {
label: "Email Request", label: "List Emails",
href: "/admin/email", href: "/admin/email",
icon: <LucideMail className="h-4 w-4" />, icon: <LucideMail className="h-4 w-4" />,
}, },
{
label: "Send a Email",
href: "/admin/email/send",
icon: <LucideMail className="h-4 w-4" />,
},
]; ];
// ADMIN CHECK FOR LOGIN // ADMIN CHECK FOR LOGIN
+48 -65
View File
@@ -5,6 +5,7 @@ import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
import { Button, Field, Input } from "@/components/ui/form";
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
@@ -30,75 +31,57 @@ export default function LoginPage() {
} }
return ( return (
<main className="min-h-screen bg-black text-zinc-50"> <main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto flex max-w-md flex-col px-4 py-10"> <div className="mx-auto flex max-w-md flex-col px-4 py-10">
<h1 className="text-2xl font-semibold tracking-tight"> <h1 className="text-2xl font-semibold tracking-tight">
Log In to <span className="text-amber-300">The Armory</span> Log In to <span className="text-amber-300">The Armory</span>
</h1> </h1>
<p className="mt-2 text-sm text-zinc-400"> <p className="mt-2 text-sm text-zinc-400">
Use your beta credentials to get back to your saved builds. Use your beta credentials to get back to your saved builds.
</p> </p>
<form onSubmit={handleSubmit} className="mt-6 space-y-4"> <form onSubmit={handleSubmit} className="mt-6 space-y-4">
{error && ( {error && (
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200"> <div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{error} {error}
</div> </div>
)} )}
<div className="space-y-1"> <Field label="Email" htmlFor="email">
<label className="text-xs font-medium text-zinc-400" htmlFor="email"> <Input
Email id="email"
</label> type="email"
<input autoComplete="email"
id="email" required
type="email" value={email}
autoComplete="email" onChange={(e) => setEmail(e.target.value)}
required />
className="w-full rounded-md border border-zinc-800 bg-zinc-900/70 px-3 py-2 text-sm text-zinc-50 focus:outline-none focus:ring-1 focus:ring-amber-400" </Field>
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-1"> <Field label="Password" htmlFor="password">
<label <Input
className="text-xs font-medium text-zinc-400" id="password"
htmlFor="password" type="password"
> autoComplete="current-password"
Password required
</label> value={password}
<input onChange={(e) => setPassword(e.target.value)}
id="password" />
type="password" </Field>
autoComplete="current-password"
required
className="w-full rounded-md border border-zinc-800 bg-zinc-900/70 px-3 py-2 text-sm text-zinc-50 focus:outline-none focus:ring-1 focus:ring-amber-400"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<button <Button type="submit" disabled={loading}>
type="submit" {loading ? "Signing in…" : "Log In"}
disabled={loading} </Button>
className="mt-2 w-full rounded-md border border-amber-400/70 bg-amber-400/20 px-3 py-2 text-sm font-semibold text-amber-100 hover:bg-amber-400/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" </form>
>
{loading ? "Signing in…" : "Log In"}
</button>
</form>
<p className="mt-4 text-xs text-zinc-500"> <p className="mt-4 text-xs text-zinc-500">
New here?{" "} New here?{" "}
<Link <Link href="/register" className="text-amber-300 hover:text-amber-200">
href="/register" Join the beta
className="text-amber-300 hover:text-amber-200" </Link>
> .
Join the beta </p>
</Link> </div>
. </main>
</p>
</div>
</main>
); );
} }
+57 -78
View File
@@ -5,6 +5,7 @@ import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
import { Button, Field, Input } from "@/components/ui/form";
export default function RegisterPage() { export default function RegisterPage() {
const router = useRouter(); const router = useRouter();
@@ -31,89 +32,67 @@ export default function RegisterPage() {
} }
return ( return (
<main className="min-h-screen bg-black text-zinc-50"> <main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto flex max-w-md flex-col px-4 py-10"> <div className="mx-auto flex max-w-md flex-col px-4 py-10">
<h1 className="text-2xl font-semibold tracking-tight"> <h1 className="text-2xl font-semibold tracking-tight">
Join the <span className="text-amber-300">Battl Builder</span> Beta Join the <span className="text-amber-300">Battl Builder</span> Beta
</h1> </h1>
<p className="mt-2 text-sm text-zinc-400"> <p className="mt-2 text-sm text-zinc-400">
Create an account so we can save your builds, watch price drops, and Create an account so we can save your builds, watch price drops, and
roll out new features to you first. roll out new features to you first.
</p> </p>
<form onSubmit={handleSubmit} className="mt-6 space-y-4"> <form onSubmit={handleSubmit} className="mt-6 space-y-4">
{error && ( {error && (
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200"> <div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{error} {error}
</div> </div>
)} )}
<div className="space-y-1"> <Field label="Display Name (optional)" htmlFor="displayName">
<label <Input
className="text-xs font-medium text-zinc-400" id="displayName"
htmlFor="displayName" type="text"
> value={displayName}
Display Name (optional) onChange={(e) => setDisplayName(e.target.value)}
</label> />
<input </Field>
id="displayName"
type="text"
className="w-full rounded-md border border-zinc-800 bg-zinc-900/70 px-3 py-2 text-sm text-zinc-50 focus:outline-none focus:ring-1 focus:ring-amber-400"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
</div>
<div className="space-y-1"> <Field label="Email" htmlFor="email">
<label className="text-xs font-medium text-zinc-400" htmlFor="email"> <Input
Email id="email"
</label> type="email"
<input autoComplete="email"
id="email" required
type="email" value={email}
autoComplete="email" onChange={(e) => setEmail(e.target.value)}
required />
className="w-full rounded-md border border-zinc-800 bg-zinc-900/70 px-3 py-2 text-sm text-zinc-50 focus:outline-none focus:ring-1 focus:ring-amber-400" </Field>
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-1"> <Field label="Password" htmlFor="password">
<label <Input
className="text-xs font-medium text-zinc-400" id="password"
htmlFor="password" type="password"
> autoComplete="new-password"
Password required
</label> value={password}
<input onChange={(e) => setPassword(e.target.value)}
id="password" />
type="password" </Field>
autoComplete="new-password"
required
className="w-full rounded-md border border-zinc-800 bg-zinc-900/70 px-3 py-2 text-sm text-zinc-50 focus:outline-none focus:ring-1 focus:ring-amber-400"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<button <Button type="submit" disabled={loading}>
type="submit" {loading ? "Creating account…" : "Join Beta"}
disabled={loading} </Button>
className="mt-2 w-full rounded-md border border-amber-400/70 bg-amber-400/20 px-3 py-2 text-sm font-semibold text-amber-100 hover:bg-amber-400/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" </form>
>
{loading ? "Creating account…" : "Join Beta"}
</button>
</form>
<p className="mt-4 text-xs text-zinc-500"> <p className="mt-4 text-xs text-zinc-500">
Already have an account?{" "} Already have an account?{" "}
<Link href="/login" className="text-amber-300 hover:text-amber-200"> <Link href="/login" className="text-amber-300 hover:text-amber-200">
Log in Log in
</Link> </Link>
. .
</p> </p>
</div> </div>
</main> </main>
); );
} }
+117
View File
@@ -0,0 +1,117 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { sendEmailAction, type ApiResponse, type EmailRequest } from "/app/actions/sendEmail";
import { Button, Field, Input, Textarea } from "@/components/ui/form";
export default function SendEmailForm(): JSX.Element {
const [result, setResult] = useState<
ApiResponse<EmailRequest> | { error: string } | null
>(null);
const [submitting, setSubmitting] = useState(false);
const [showSuccess, setShowSuccess] = useState(false);
const successTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (successTimerRef.current) clearTimeout(successTimerRef.current);
};
}, []);
async function onSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
e.preventDefault();
setResult(null);
setShowSuccess(false);
if (successTimerRef.current) clearTimeout(successTimerRef.current);
const form = new FormData(e.currentTarget);
const recipient = String(form.get("recipient") ?? "");
const subject = String(form.get("subject") ?? "");
const body = String(form.get("body") ?? "");
try {
setSubmitting(true);
const data = await sendEmailAction({ recipient, subject, body });
setResult(data);
if (data?.success === true) {
setShowSuccess(true);
successTimerRef.current = setTimeout(() => {
setShowSuccess(false);
setResult(null); // clear the response panel too
}, 2000);
}
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
setResult({ error: message });
} finally {
setSubmitting(false);
}
}
return (
<div className="mx-auto w-full max-w-xl px-4 py-10 text-zinc-50">
<h1 className="text-2xl font-semibold tracking-tight">
Send <span className="text-amber-300">Email</span>
</h1>
<p className="mt-2 text-sm text-zinc-400">
Compose a message and send it from the admin panel.
</p>
<form onSubmit={onSubmit} className="mt-6 space-y-4">
{result && "error" in result && (
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{result.error}
</div>
)}
<Field label="Recipient" htmlFor="recipient">
<Input
id="recipient"
name="recipient"
type="email"
placeholder="user@example.com"
defaultValue="user@example.com"
required
/>
</Field>
<Field label="Subject" htmlFor="subject">
<Input
id="subject"
name="subject"
placeholder="Test subject"
defaultValue="Test subject"
required
/>
</Field>
<Field label="Body" htmlFor="body">
<Textarea
id="body"
name="body"
placeholder="Write your message…"
defaultValue="Test body"
rows={6}
required
/>
</Field>
<Button type="submit" disabled={submitting}>
{submitting ? "Sending…" : showSuccess ? "Success" : "Send"}
</Button>
<div className="rounded-md border border-zinc-800 bg-zinc-950/60 p-3">
<div className="text-xs font-medium text-zinc-400">Response</div>
<pre className="mt-2 overflow-x-auto text-xs text-zinc-200">
{result ? JSON.stringify(result, null, 2) : "No response yet."}
</pre>
</div>
</form>
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
// components/ui/form.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/cn";
export const labelClass = "text-xs font-medium text-zinc-400";
export const inputBaseClass =
"w-full rounded-md border border-zinc-800 bg-zinc-900/70 px-3 py-2 text-sm text-zinc-50 " +
"placeholder:text-zinc-500 focus:outline-none focus:ring-1 focus:ring-amber-400";
export const buttonBaseClass =
"w-full rounded-md border border-amber-400/70 bg-amber-400/20 px-3 py-2 text-sm font-semibold text-amber-100 " +
"hover:bg-amber-400/30 disabled:cursor-not-allowed disabled:opacity-50 transition-colors";
export const Input = React.forwardRef<
HTMLInputElement,
React.InputHTMLAttributes<HTMLInputElement>
>(function Input({ className, ...props }, ref) {
return <input ref={ref} className={cn(inputBaseClass, className)} {...props} />;
});
export const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>(function Textarea({ className, ...props }, ref) {
return (
<textarea ref={ref} className={cn(inputBaseClass, className)} {...props} />
);
});
export function Button({
className,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return <button className={cn(buttonBaseClass, className)} {...props} />;
}
type FieldProps = {
label: string;
htmlFor: string;
hint?: React.ReactNode;
error?: React.ReactNode;
className?: string;
children: React.ReactNode;
};
export function Field({
label,
htmlFor,
hint,
error,
className,
children,
}: FieldProps) {
return (
<div className={cn("space-y-1", className)}>
<label className={labelClass} htmlFor={htmlFor}>
{label}
</label>
{children}
{error ? (
<p className="text-xs text-red-300">{error}</p>
) : hint ? (
<p className="text-xs text-zinc-500">{hint}</p>
) : null}
</div>
);
}
View File
+6
View File
@@ -0,0 +1,6 @@
// lib/cn.ts
import clsx, { type ClassValue } from "clsx";
export function cn(...inputs: ClassValue[]) {
return clsx(inputs);
}