-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
12 changed files
with
333 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
import { SentMessageInfo, createTransport } from "nodemailer"; | ||
import { Contact } from "../../contact/contact"; | ||
|
||
const { SMTP_USER: user, SMTP_PASSWORD: pass, SMTP_RECEIVER: to } = process.env; | ||
|
||
const transporter = createTransport({ | ||
service: "gmail", | ||
auth: { | ||
user, | ||
pass, | ||
}, | ||
}); | ||
|
||
export function sendMail({ | ||
name, | ||
email, | ||
subject, | ||
text, | ||
}: Contact): Promise<SentMessageInfo> { | ||
return transporter.sendMail({ | ||
from: user, | ||
to, | ||
subject, | ||
html: `${name} <${email}><br><br>${text}`, | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
import { NextRequest, NextResponse } from "next/server"; | ||
import { Contact, fields } from "../../contact/contact"; | ||
import { isValid } from "../../forms/validator"; | ||
import { sendMail } from "./mailer"; | ||
|
||
function isContact(obj: unknown): obj is Contact { | ||
return isValid({ obj, fields }); | ||
} | ||
|
||
export async function POST(request: NextRequest) { | ||
const form = Object.fromEntries(await request.formData()); | ||
|
||
if (!isContact(form)) { | ||
return NextResponse.json({ error: "Invalid form data" }, { status: 400 }); | ||
} | ||
|
||
try { | ||
await sendMail(form); | ||
|
||
return NextResponse.json({ | ||
message: `Successfully sent email to ${form.email}`, | ||
}); | ||
} catch (error) { | ||
return NextResponse.json( | ||
{ message: "Failed to send email" }, | ||
{ status: 500 }, | ||
); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
"use client"; | ||
import { useState } from "react"; | ||
import { BiMailSend } from "react-icons/bi"; | ||
import { Input } from "../../forms/components/Input"; | ||
import { Submit } from "../../forms/components/Submit"; | ||
import { fields } from "../contact"; | ||
|
||
export function ContactForm() { | ||
const [isSending, setIsSending] = useState(false); | ||
|
||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { | ||
e.preventDefault(); | ||
setIsSending(true); | ||
const body = new FormData(e.currentTarget); | ||
await fetch("/api/contact", { method: "POST", body }); | ||
setIsSending(false); | ||
} | ||
|
||
return ( | ||
<form onSubmit={handleSubmit} className="flex flex-col gap-6"> | ||
<div className="flex flex-col gap-4"> | ||
<Input field={fields.name} disabled={isSending} /> | ||
<Input field={fields.email} disabled={isSending} /> | ||
<Input field={fields.subject} disabled={isSending} /> | ||
<Input field={fields.text} disabled={isSending} /> | ||
</div> | ||
<div className="text-center"> | ||
<Submit | ||
isSubmitting={isSending} | ||
icon={<BiMailSend className="text-2xl" />} | ||
/> | ||
</div> | ||
</form> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
import { Fields } from "../forms/models"; | ||
|
||
export interface Contact { | ||
name: string; | ||
email: string; | ||
subject: string; | ||
text: string; | ||
} | ||
|
||
export const fields: Fields<Contact> = { | ||
name: { | ||
key: "name", | ||
type: "text", | ||
label: "Name", | ||
validators: { required: true, minLength: 2, maxLength: 30 }, | ||
}, | ||
email: { | ||
key: "email", | ||
type: "email", | ||
label: "Email", | ||
validators: { required: true, minLength: 5, maxLength: 50 }, | ||
}, | ||
subject: { | ||
key: "subject", | ||
type: "text", | ||
label: "Subject", | ||
validators: { required: true, minLength: 2, maxLength: 60 }, | ||
}, | ||
text: { | ||
key: "text", | ||
type: "textarea", | ||
label: "Text", | ||
validators: { maxLength: 500 }, | ||
}, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
import { render, screen } from "@testing-library/react"; | ||
import ContactPage, { metadata } from "./page"; | ||
|
||
const ContactForm = "ContactForm"; | ||
|
||
jest.mock("./components/ContactForm", () => ({ | ||
ContactForm: () => <div data-testid={ContactForm}></div>, | ||
})); | ||
|
||
describe("Contact", () => { | ||
it("should contain metadata", () => { | ||
expect(metadata.title).toBeTruthy(); | ||
}); | ||
|
||
it("should render title", () => { | ||
render(<ContactPage />); | ||
|
||
expect( | ||
screen.getByRole("heading", { name: "Contact" }), | ||
).toBeInTheDocument(); | ||
}); | ||
|
||
it("should render ContactForm", () => { | ||
render(<ContactPage />); | ||
|
||
expect(screen.getByTestId(ContactForm)).toBeInTheDocument(); | ||
expect(screen.queryAllByTestId(ContactForm)).toHaveLength(1); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
import type { Metadata } from "next"; | ||
import { ContactForm } from "./components/ContactForm"; | ||
|
||
export const metadata: Metadata = { | ||
title: "Contact", | ||
}; | ||
|
||
export default function ContactPage() { | ||
return ( | ||
<div className="mt-12 sm:mt-6"> | ||
<section className="max-w-[600px] mx-auto"> | ||
<h1 className="text-3xl text-primary text-center">Contact</h1> | ||
<ContactForm /> | ||
</section> | ||
</div> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
import { Field } from "../models"; | ||
|
||
interface InputProps { | ||
field: Field<string>; | ||
disabled: boolean; | ||
} | ||
|
||
const className = "px-5 py-2 border bg-input-bg rounded-md shadow-sm"; | ||
|
||
export function Input({ | ||
field: { label, type, validators = {}, ...props }, | ||
disabled, | ||
}: InputProps) { | ||
const furtherProps = { | ||
...props, | ||
...validators, | ||
id: label, | ||
name: label, | ||
disabled, | ||
className, | ||
}; | ||
|
||
return ( | ||
<div className="flex flex-col gap-2 text-secondary"> | ||
<label className="text-lg" htmlFor={label}> | ||
{label} | ||
</label> | ||
{type === "textarea" ? ( | ||
<textarea {...furtherProps} /> | ||
) : ( | ||
<input type={type} {...furtherProps} /> | ||
)} | ||
</div> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
import { motion } from "framer-motion"; | ||
|
||
interface SubmitProps { | ||
isSubmitting: boolean; | ||
icon: React.ReactNode; | ||
} | ||
|
||
export function Submit({ isSubmitting, icon }: SubmitProps) { | ||
return ( | ||
<button | ||
type="submit" | ||
disabled={isSubmitting} | ||
className="p-3 text-primary text-lg shadow-sm rounded-md bg-primary-bg hover:bg-secondary-bg" | ||
> | ||
<div className="relative"> | ||
<span className={isSubmitting ? "invisible" : ""}>Submit</span> | ||
{isSubmitting && ( | ||
<motion.div | ||
initial={{ | ||
opacity: 0, | ||
left: 0, | ||
translateX: 0, | ||
translateY: "-50%", | ||
}} | ||
animate={{ | ||
opacity: 1, | ||
left: "100%", | ||
translateX: "-100%", | ||
}} | ||
transition={{ | ||
repeat: Infinity, | ||
duration: 1, | ||
}} | ||
className="absolute top-[50%]" | ||
> | ||
{icon} | ||
</motion.div> | ||
)} | ||
</div> | ||
</button> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
export type InputType = "text" | "email" | "textarea"; | ||
|
||
export interface Validators { | ||
required?: boolean; | ||
minLength?: number; | ||
maxLength?: number; | ||
} | ||
|
||
export interface Field<K> { | ||
key: K; | ||
type: InputType; | ||
label: string; | ||
validators?: Validators; | ||
} | ||
|
||
export type Fields<T> = { | ||
[K in keyof T]: Field<K>; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
import { Field, Fields, InputType } from "./models"; | ||
|
||
const inputTypeToType: Record<InputType, "string"> = { | ||
text: "string", | ||
email: "string", | ||
textarea: "string", | ||
}; | ||
|
||
export function isValid<T>({ | ||
obj, | ||
fields, | ||
}: { | ||
obj: unknown; | ||
fields: Fields<T>; | ||
}): boolean { | ||
if (typeof obj !== "object" || obj === null) { | ||
return false; | ||
} | ||
|
||
return (Object.entries(fields) as [keyof Fields<T>, Field<keyof T>][]).every( | ||
([key, { type, validators }]) => { | ||
const { required, minLength, maxLength } = validators || {}; | ||
const value = (obj as T)[key]; | ||
|
||
if (required && (value === undefined || value === null)) { | ||
return false; | ||
} | ||
|
||
if (typeof value !== inputTypeToType[type]) { | ||
return false; | ||
} | ||
|
||
if ( | ||
value === "email" && | ||
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value as string) | ||
) { | ||
return false; | ||
} | ||
|
||
if ( | ||
typeof minLength === "number" && | ||
(value as string).length < minLength | ||
) { | ||
return false; | ||
} | ||
|
||
if ( | ||
typeof maxLength === "number" && | ||
(value as string).length > maxLength | ||
) { | ||
return false; | ||
} | ||
|
||
return true; | ||
}, | ||
); | ||
} |