Skip to content

Commit

Permalink
Implement the Contact Form's UI
Browse files Browse the repository at this point in the history
Signed-off-by: Andrés Vidal <[email protected]>
  • Loading branch information
andres-vidal committed Mar 11, 2024
1 parent 16acb40 commit c7ab71b
Show file tree
Hide file tree
Showing 8 changed files with 374 additions and 64 deletions.
14 changes: 14 additions & 0 deletions frontend/components/ContactButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { FC } from "react";
import Button from "./Button";
import { CONTACT_MODAL_KEY } from "./ContactModal";
import { useURLQueryModal } from "./Modal";

const ContactButton: FC = () => {
const modal = useURLQueryModal(CONTACT_MODAL_KEY);

return (
<Button variant="secondary" label="Contact" onClick={() => modal.open()} />
);
};

export { ContactButton };
134 changes: 134 additions & 0 deletions frontend/components/ContactModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { FC, FormEvent } from "react";
import { useForm } from "utils/useForm";
import { z } from "zod";
import { Article } from "./Article";
import Button from "./Button";
import { Modal, useURLQueryModal } from "./Modal";
import { useNotify } from "./Notifications";
import TextArea from "./TextArea";
import TextInput from "./TextInput";

const CONTACT_MODAL_KEY = "contact";

const Contact = z.object({
name: z.string().min(1, "Required"),
institution: z.string().optional(),
address: z.string().min(1, "Required"),
subject: z.string().min(1, "Required"),
message: z.string().min(1, "Required"),
});

type Contact = z.infer<typeof Contact>;

const SENDER_INTRODUCTION = `Please share your contact details to enable us to address your query promptly. Your full name and email address are essential for us to send you a response. If you're associated with an institution and you think it's relevant to your message, feel free to include that information as well, though it's completely optional. We assure you that your details will be kept confidential and used solely for communication purposes related to your inquiry.`;
const MESSAGE_INTRODUCTION = `Kindly provide a concise description of your purpose for reaching out. Whether you're interested in collaborating with our research, offering feedback on our data, or sharing insights about our web application, we invite you to outline your thoughts succinctly. This initial communication is an important step in establishing a productive dialogue. We're committed to understanding your perspective and exploring how we can effectively respond or engage in further discussions.`;

const ContactForm: FC = () => {
const { close } = useURLQueryModal(CONTACT_MODAL_KEY);
const notify = useNotify();

const [contact, setContact, errors] = useForm(Contact);

const {
name = "",
institution = "",
subject = "",
address = "",
message = "",
} = contact;

async function handleSubmit(event: FormEvent) {
event.preventDefault();

const { success } = Contact.safeParse(contact);

if (!success) {
setContact({
name,
institution,
subject,
address,
message,
});
return;
}

notify({
level: "success",
message: "Your message has been sent!",
});

close();
}

function handleChange(key: keyof Contact) {
return (value: string) => {
setContact({ ...contact, [key]: value });
};
}

return (
<form
className="py-4 space-y-5 text-sm sm:text-base"
onSubmit={handleSubmit}
>
<section className="space-y-6">
<p>{SENDER_INTRODUCTION}</p>
<fieldset className="space-y-6">
<TextInput
label="Name"
value={name}
onChange={handleChange("name")}
error={errors.name}
/>
<TextInput
label="Email"
value={address}
onChange={handleChange("address")}
error={errors.address}
/>
<TextInput
label="Institution"
value={institution}
onChange={handleChange("institution")}
error={errors.institution}
/>
</fieldset>
</section>

<section className="space-y-6">
<p>{MESSAGE_INTRODUCTION}</p>
<fieldset className="space-y-6">
<TextInput
label="Subject"
value={subject}
onChange={handleChange("subject")}
error={errors.subject}
/>
<TextArea
label="Write your message here..."
value={message}
onChange={handleChange("message")}
error={errors.message}
/>
</fieldset>
</section>

<footer className="flex justify-end gap-2">
<Button label="Cancel" variant="outline" onClick={close} />
<Button type="submit" label="Send" />
</footer>
</form>
);
};

const ContactModal: FC = () => {
const { isOpen, close } = useURLQueryModal(CONTACT_MODAL_KEY);
return (
<Modal isOpen={isOpen} onClose={close}>
<Article heading={<div>Contact Us</div>} content={<ContactForm />} />
</Modal>
);
};

export { CONTACT_MODAL_KEY, ContactModal };
72 changes: 38 additions & 34 deletions frontend/components/Notifications.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { FloatingPortal } from "@floating-ui/react";
import classNames from "classnames";
import { motion, AnimatePresence } from "framer-motion";
import { AnimatePresence, motion } from "framer-motion";
import { useRouter } from "next/router";
import { FC, useEffect } from "react";
import {
Expand Down Expand Up @@ -47,7 +48,7 @@ const Notifications: FC = () => {
}
}, [notifications, setNotifications]);

useEffect(() => resetNotifications, [router, resetNotifications]);
useEffect(() => resetNotifications, [router.pathname, resetNotifications]);

const shownNotificationsCount =
notifications.length === MAX_SNACKBARS
Expand All @@ -67,38 +68,41 @@ const Notifications: FC = () => {
});

return (
<section className="fixed z-50 flex flex-col items-center space-y-2 -translate-x-1/2 left-1/2 top-10">
<AnimatePresence>
{snackbars.map(
({ key, message, level }) =>
(key !== "notification-stack" || stackedNotificationsCount > 0) && (
<motion.div
layout
key={key}
className="flex py-2 pl-1 pr-3 space-x-3 bg-white rounded shadow-md w-96"
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
transition={{ duration: 0.1 }}
aria-modal="true"
aria-describedby="snackbar-message"
aria-label="Notification"
>
<div
role="presentation"
className={classNames(
"flex items-center justify-center w-7 h-6",
{ "text-indigo-700 text-xl": level === "info" },
)}
<FloatingPortal>
<section className="fixed z-[60] flex flex-col items-center space-y-2 -translate-x-1/2 left-1/2 top-10">
<AnimatePresence>
{snackbars.map(
({ key, message, level }) =>
(key !== "notification-stack" ||
stackedNotificationsCount > 0) && (
<motion.div
layout
key={key}
className="flex py-2 pl-1 pr-3 space-x-3 bg-white rounded shadow-md w-96"
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
transition={{ duration: 0.1 }}
aria-modal="true"
aria-describedby="snackbar-message"
aria-label="Notification"
>
{NOTIFICATION_ICONS[level]}
</div>
<label id={`snackbar-message-${key}`}>{message}</label>
</motion.div>
),
)}
</AnimatePresence>
</section>
<div
role="presentation"
className={classNames(
"flex items-center justify-center w-7 h-6",
{ "text-indigo-700 text-xl": level === "info" },
)}
>
{NOTIFICATION_ICONS[level]}
</div>
<label id={`snackbar-message-${key}`}>{message}</label>
</motion.div>
),
)}
</AnimatePresence>
</section>
</FloatingPortal>
);
};

Expand All @@ -121,4 +125,4 @@ function useNotify(): Notifier {
}

export default Notifications;
export { useNotify, NOTIFICATIONS as _NOTIFICATIONS, _notify };
export { NOTIFICATIONS as _NOTIFICATIONS, _notify, useNotify };
77 changes: 77 additions & 0 deletions frontend/components/TextArea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import c from "classnames";
import {
ChangeEvent,
forwardRef,
HTMLProps,
ReactNode,
Ref,
useId,
} from "react";

type Props = Omit<
HTMLProps<HTMLTextAreaElement>,
"value" | "onChange" | "className" | "label"
> & {
value: string;
error?: string;
onChange?: (value: string) => void;
left?: ReactNode;
right?: ReactNode;
inputRef?: Ref<HTMLTextAreaElement>;
label?: string;
};

export default forwardRef<HTMLDivElement, Props>(function TextArea(
{ value, error, left, right, inputRef, onChange, label, ...props },
ref,
) {
const labelId = useId();
const errorId = useId();

function handleChange(event: ChangeEvent<HTMLTextAreaElement>) {
onChange?.(event.target.value);
}

return (
<div
ref={ref}
className={c(
"relative group",
"w-full gap-1 inline-flex items-center rounded h-full scrollbar scrollbar-none",
"error-within:shadow-sm focus-within:error-within:bg-red-400/80, error-within:bg-red-300/40",
label
? "mt-2 px-2 py-2 bg-gray-active shadow-sm focus-within:bg-indigo-500/10"
: "text-xs p-1 overflow-x-scroll focus-within:bg-gray-active focus-within:shadow-sm",
)}
>
{left}
<textarea
{...props}
ref={inputRef}
value={value}
className="w-full px-1 bg-transparent outline-none min-h-40 peer shrink grow placeholder:text-xs error:focus:text-white error:placeholder-white"
onChange={handleChange}
data-error={Boolean(error)}
placeholder={label ? "" : props.placeholder}
aria-labelledby={labelId}
aria-errormessage={errorId}
aria-invalid={Boolean(error)}
/>
{label && (
<label
id={labelId}
className={c(
"absolute transition-all pointer-events-none",
"peer-placeholder-shown:text-gray-600 peer-placeholder-shown:text-sm text-xs peer-focus:text-xs text-indigo-600 peer-focus:text-indigo-600",
"peer-placeholder-shown:left-2 left-0 peer-focus:left-0 ",
"peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:top-4 -translate-y-full peer-focus:-translate-y-full top-0 peer-focus:top-0",
"peer-error:text-red-600 peer-error:-translate-y-full peer-error:top-0 peer-error:left-0 peer-error:text-xs",
)}
>
{label} {error && <span id={errorId}>({error})</span>}
</label>
)}
{right}
</div>
);
});
Loading

0 comments on commit c7ab71b

Please sign in to comment.