feat(storefront): update FAQ and legal documentation
- Added new FAQ sections for account security, ordering and checkout, returns, shipping, and contact information. - Introduced legal documents including privacy policy, terms of service, data protection, and general terms and conditions. - Updated package dependencies to include gray-matter and remark-gfm for enhanced markdown support.
This commit is contained in:
230
apps/storefront/src/components/contact/ContactForm.tsx
Normal file
230
apps/storefront/src/components/contact/ContactForm.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "convex/react";
|
||||
import { api } from "../../../../../convex/_generated/api";
|
||||
import type { Key } from "react-aria";
|
||||
import {
|
||||
Form,
|
||||
TextField,
|
||||
Label,
|
||||
Input,
|
||||
TextArea,
|
||||
FieldError,
|
||||
Button,
|
||||
Spinner,
|
||||
toast,
|
||||
Select,
|
||||
ListBox,
|
||||
} from "@heroui/react";
|
||||
|
||||
const TOPIC_OPTIONS = [
|
||||
{ key: "products", label: "Products" },
|
||||
{ key: "orders", label: "Orders" },
|
||||
{ key: "support", label: "Support" },
|
||||
{ key: "other", label: "Other" },
|
||||
] as const;
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const MAX_NAME = 200;
|
||||
const MAX_EMAIL = 254;
|
||||
const MAX_MESSAGE = 5000;
|
||||
|
||||
export function ContactForm() {
|
||||
const submitMessage = useMutation(api.messages.submit);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [topicKey, setTopicKey] = useState<Key | null>(null);
|
||||
const [topicError, setTopicError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setTopicError(null);
|
||||
|
||||
const form = e.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
const fullName = (formData.get("fullName") as string)?.trim() ?? "";
|
||||
const email = (formData.get("email") as string)?.trim() ?? "";
|
||||
const message = (formData.get("message") as string)?.trim() ?? "";
|
||||
|
||||
// Client-side validation
|
||||
if (!fullName) {
|
||||
toast.danger("Please enter your full name.");
|
||||
return;
|
||||
}
|
||||
if (fullName.length > MAX_NAME) {
|
||||
toast.danger(`Full name must be at most ${MAX_NAME} characters.`);
|
||||
return;
|
||||
}
|
||||
if (!email) {
|
||||
toast.danger("Please enter your work email.");
|
||||
return;
|
||||
}
|
||||
if (!EMAIL_REGEX.test(email)) {
|
||||
toast.danger("Please enter a valid email address.");
|
||||
return;
|
||||
}
|
||||
if (email.length > MAX_EMAIL) {
|
||||
toast.danger("Email must be at most 254 characters.");
|
||||
return;
|
||||
}
|
||||
const topic = topicKey as string | null;
|
||||
if (!topic || !TOPIC_OPTIONS.some((o) => o.key === topic)) {
|
||||
setTopicError("Please select a topic.");
|
||||
toast.danger("Please select a topic.");
|
||||
return;
|
||||
}
|
||||
if (!message) {
|
||||
toast.danger("Please enter your message.");
|
||||
return;
|
||||
}
|
||||
if (message.length > MAX_MESSAGE) {
|
||||
toast.danger(`Message must be at most ${MAX_MESSAGE} characters.`);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await submitMessage({
|
||||
fullName,
|
||||
email,
|
||||
topic: topic as "products" | "orders" | "support" | "other",
|
||||
message,
|
||||
});
|
||||
toast.success("Thank you! We've received your message and will get back to you soon.");
|
||||
form.reset();
|
||||
setTopicKey(null);
|
||||
setTopicError(null);
|
||||
} catch (err: unknown) {
|
||||
const messageErr = err instanceof Error ? err.message : "Something went wrong. Please try again.";
|
||||
toast.danger(messageErr);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit} className="flex flex-col gap-5 w-full">
|
||||
<TextField
|
||||
isRequired
|
||||
name="fullName"
|
||||
maxLength={MAX_NAME}
|
||||
className="flex flex-col gap-1"
|
||||
aria-required="true"
|
||||
>
|
||||
<Label className="text-sm font-medium text-[var(--foreground)]">
|
||||
Full name <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="First and last name"
|
||||
className="bg-[var(--surface)]"
|
||||
autoComplete="name"
|
||||
disabled={isSubmitting}
|
||||
aria-required="true"
|
||||
/>
|
||||
<FieldError className="text-xs text-danger" />
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
isRequired
|
||||
name="email"
|
||||
type="email"
|
||||
maxLength={MAX_EMAIL}
|
||||
validate={(val: string) => {
|
||||
if (val && !EMAIL_REGEX.test(val)) return "Please enter a valid email address.";
|
||||
}}
|
||||
className="flex flex-col gap-1"
|
||||
aria-required="true"
|
||||
>
|
||||
<Label className="text-sm font-medium text-[var(--foreground)]">
|
||||
Work email address <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="me@company.com"
|
||||
className="bg-[var(--surface)]"
|
||||
autoComplete="email"
|
||||
disabled={isSubmitting}
|
||||
aria-required="true"
|
||||
/>
|
||||
<FieldError className="text-xs text-danger" />
|
||||
</TextField>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium text-[var(--foreground)]">
|
||||
Topic <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
aria-label="Select a topic"
|
||||
aria-required="true"
|
||||
placeholder="Select a topic"
|
||||
value={topicKey}
|
||||
onChange={(value) => {
|
||||
setTopicKey(value ?? null);
|
||||
setTopicError(null);
|
||||
}}
|
||||
isDisabled={isSubmitting}
|
||||
className="w-full"
|
||||
>
|
||||
<Select.Trigger>
|
||||
<Select.Value />
|
||||
<Select.Indicator />
|
||||
</Select.Trigger>
|
||||
<Select.Popover className="rounded-lg">
|
||||
<ListBox>
|
||||
{TOPIC_OPTIONS.map((opt) => (
|
||||
<ListBox.Item key={opt.key} id={opt.key} textValue={opt.label}>
|
||||
{opt.label}
|
||||
<ListBox.ItemIndicator />
|
||||
</ListBox.Item>
|
||||
))}
|
||||
</ListBox>
|
||||
</Select.Popover>
|
||||
</Select>
|
||||
{topicError && (
|
||||
<p className="text-xs text-danger mt-1" role="alert">
|
||||
{topicError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
isRequired
|
||||
name="message"
|
||||
maxLength={MAX_MESSAGE}
|
||||
className="flex flex-col gap-1"
|
||||
aria-required="true"
|
||||
>
|
||||
<Label className="text-sm font-medium text-[var(--foreground)]">
|
||||
Your message <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<TextArea
|
||||
rows={5}
|
||||
placeholder="Write your message"
|
||||
className="bg-[var(--surface)]"
|
||||
disabled={isSubmitting}
|
||||
aria-required="true"
|
||||
/>
|
||||
<FieldError className="text-xs text-danger" />
|
||||
</TextField>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
isDisabled={isSubmitting}
|
||||
isPending={isSubmitting}
|
||||
className="bg-[#f4a13a] text-[#1a2e2d] font-medium w-full md:w-auto md:self-start mt-1"
|
||||
aria-busy={isSubmitting}
|
||||
>
|
||||
{({ isPending }: { isPending: boolean }) =>
|
||||
isPending ? (
|
||||
<>
|
||||
<Spinner color="current" size="sm" />
|
||||
Submitting…
|
||||
</>
|
||||
) : (
|
||||
"Submit"
|
||||
)
|
||||
}
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user