From c7b2b8a1de56bcc5fe5c2e540c04737978db5251 Mon Sep 17 00:00:00 2001 From: Mahdi Khanzadi Date: Sun, 2 Aug 2026 13:19:36 +0200 Subject: [PATCH] Feat: contact-us A public page where a visitor can send a message without an account, and a dashboard section for reading and triaging what arrives. - /{lang}/contact-us with the form; subject and body are required, and at least one of email or phone so there is a way to reply - /dashboard/contact-us lists messages with a read/unread toggle, a detail view, and delete, each gated on its contactus.* permission - A "contact us" link joins the footer, which is regrouped so the site links sit together and the tagline sits on the opposite edge - Public and private DAL modules for the endpoints, and en/fa strings Co-Authored-By: Claude Opus 5 --- .../dashboard/contact-us/[uuid]/layout.tsx | 28 ++++ .../dashboard/contact-us/[uuid]/page.tsx | 36 +++++ .../(dashboard)/dashboard/contact-us/page.tsx | 52 ++++++ src/app/(public)/[lang]/contact-us/page.tsx | 38 +++++ src/app/(public)/footer.tsx | 75 +++++---- src/dal/private/contact.ts | 32 ++++ src/dal/public/contact.ts | 13 ++ .../contact/actions/delete-message.ts | 24 +++ src/features/contact/actions/mark-as-read.ts | 24 +++ src/features/contact/actions/send-message.ts | 37 +++++ .../components/contact-form/contact-form.tsx | 110 +++++++++++++ .../contact/components/contact-form/index.ts | 1 + .../components/message-detail/index.ts | 1 + .../message-detail/message-detail.tsx | 92 +++++++++++ .../messages-table/delete-button.tsx | 76 +++++++++ .../components/messages-table/index.ts | 2 + .../components/messages-table/read-toggle.tsx | 47 ++++++ .../messages-table/table-skeleton.tsx | 13 ++ .../components/messages-table/table.tsx | 152 ++++++++++++++++++ .../components/layout-sidebar.tsx | 7 + src/i18n/dictionaries/en.json | 51 +++++- src/i18n/dictionaries/fa.json | 51 +++++- src/lib/app-paths.ts | 5 + src/lib/app-permissions.ts | 6 + 24 files changed, 939 insertions(+), 34 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/contact-us/[uuid]/layout.tsx create mode 100644 src/app/(dashboard)/dashboard/contact-us/[uuid]/page.tsx create mode 100644 src/app/(dashboard)/dashboard/contact-us/page.tsx create mode 100644 src/app/(public)/[lang]/contact-us/page.tsx create mode 100644 src/dal/private/contact.ts create mode 100644 src/dal/public/contact.ts create mode 100644 src/features/contact/actions/delete-message.ts create mode 100644 src/features/contact/actions/mark-as-read.ts create mode 100644 src/features/contact/actions/send-message.ts create mode 100644 src/features/contact/components/contact-form/contact-form.tsx create mode 100644 src/features/contact/components/contact-form/index.ts create mode 100644 src/features/contact/components/message-detail/index.ts create mode 100644 src/features/contact/components/message-detail/message-detail.tsx create mode 100644 src/features/contact/components/messages-table/delete-button.tsx create mode 100644 src/features/contact/components/messages-table/index.ts create mode 100644 src/features/contact/components/messages-table/read-toggle.tsx create mode 100644 src/features/contact/components/messages-table/table-skeleton.tsx create mode 100644 src/features/contact/components/messages-table/table.tsx diff --git a/src/app/(dashboard)/dashboard/contact-us/[uuid]/layout.tsx b/src/app/(dashboard)/dashboard/contact-us/[uuid]/layout.tsx new file mode 100644 index 00000000..26679371 --- /dev/null +++ b/src/app/(dashboard)/dashboard/contact-us/[uuid]/layout.tsx @@ -0,0 +1,28 @@ +import {ReactNode} from "react"; +import {Box} from "@mantine/core"; +import {DashboardBreadcrumbs} from "@/features/breadcrumbs/components/breadcrumbs"; +import {APP_PATHS} from "@/lib/app-paths"; +import {getServerDictionary} from "@/i18n/server"; + +async function ContactMessageLayout({children}: {children: ReactNode}) { + const {t} = await getServerDictionary(); + + return ( + <> + + {children} + + ); +} + +export default ContactMessageLayout; diff --git a/src/app/(dashboard)/dashboard/contact-us/[uuid]/page.tsx b/src/app/(dashboard)/dashboard/contact-us/[uuid]/page.tsx new file mode 100644 index 00000000..c78cca32 --- /dev/null +++ b/src/app/(dashboard)/dashboard/contact-us/[uuid]/page.tsx @@ -0,0 +1,36 @@ +import {type Metadata} from "next"; +import {notFound} from "next/navigation"; +import {ContactMessageDetail} from "@/features/contact/components/message-detail"; +import {fetchContactMessage} from "@/dal/private/contact"; +import {withPermissions} from "@/components/with-authorization"; +import {getServerDictionary} from "@/i18n/server"; + +export async function generateMetadata(): Promise { + const {t} = await getServerDictionary(); + + return { + title: t("contactUs.dashboard.detailTitle"), + }; +} + +type Props = { + params: Promise<{ + uuid?: string; + }>; +}; + +async function ContactMessagePage({params}: Props) { + const {uuid} = await params; + + if (uuid === undefined) { + notFound(); + } + + const message = await fetchContactMessage(uuid); + + return ; +} + +export default withPermissions(ContactMessagePage, { + requiredPermissions: ["contactus.show"], +}); diff --git a/src/app/(dashboard)/dashboard/contact-us/page.tsx b/src/app/(dashboard)/dashboard/contact-us/page.tsx new file mode 100644 index 00000000..0a091e93 --- /dev/null +++ b/src/app/(dashboard)/dashboard/contact-us/page.tsx @@ -0,0 +1,52 @@ +import {type Metadata} from "next"; +import {Suspense} from "react"; +import {Box} from "@mantine/core"; +import {DashboardBreadcrumbs} from "@/features/breadcrumbs/components/breadcrumbs"; +import { + ContactMessagesTable, + ContactMessagesTableSkeleton, +} from "@/features/contact/components/messages-table"; +import {withPermissions} from "@/components/with-authorization"; +import {APP_PATHS} from "@/lib/app-paths"; +import {getServerDictionary} from "@/i18n/server"; + +export async function generateMetadata(): Promise { + const {t} = await getServerDictionary(); + + return { + title: t("contactUs.dashboard.title"), + }; +} + +type Props = { + searchParams: Promise<{ + page?: string; + }>; +}; + +async function ContactUsPage({searchParams}: Props) { + const {t} = await getServerDictionary(); + const {page} = await searchParams; + + return ( + + + + }> + + + + + ); +} + +export default withPermissions(ContactUsPage, { + requiredPermissions: ["contactus.index"], +}); diff --git a/src/app/(public)/[lang]/contact-us/page.tsx b/src/app/(public)/[lang]/contact-us/page.tsx new file mode 100644 index 00000000..4bf07dbd --- /dev/null +++ b/src/app/(public)/[lang]/contact-us/page.tsx @@ -0,0 +1,38 @@ +import {type Metadata} from "next"; +import {Container} from "@mantine/core"; +import {ContactForm} from "@/features/contact/components/contact-form"; +import {getDictionary} from "@/i18n/dictionary"; + +type Props = { + params: Promise<{ + lang: string; + }>; +}; + +export async function generateMetadata(props: Props): Promise { + const {lang} = await props.params; + const {t} = getDictionary(lang); + + return { + title: t("contactUs.form.title"), + description: t("contactUs.form.description"), + }; +} + +async function ContactUsPage() { + return ( + // Same measure as the article detail page, so a text-heavy page reads at one + // consistent width across the public site. + + + + ); +} + +export default ContactUsPage; diff --git a/src/app/(public)/footer.tsx b/src/app/(public)/footer.tsx index ee5486a8..a3ed7ce8 100644 --- a/src/app/(public)/footer.tsx +++ b/src/app/(public)/footer.tsx @@ -1,49 +1,64 @@ "use client"; import {Anchor, Box, Group, Text} from "@mantine/core"; -import {IconBrandGithub} from "@tabler/icons-react"; +import {IconBrandGithub, IconMail} from "@tabler/icons-react"; +import Link from "@/components/link"; +import {APP_PATHS} from "@/lib/app-paths"; import {useTranslations} from "@/i18n/provider"; +// Every footer link reads as plain text with its icon and label on one line. +const linkStyle = { + textDecoration: "none", + display: "flex", + alignItems: "center", + gap: "0.3rem", +}; + export default function Footer() { const t = useTranslations(); + return ( - - - - {t("footer.tagline")} - - + {/* The document's `dir` flips the row, so the first child lands on the + inline start — the right in Farsi, the left in English — and the rest + cluster on the opposite edge. No per-language positioning needed. */} + + + + + + {t("footer.contactUs")} + + - - + + + + {t("footer.openSource")} + + + + + - {t("footer.openSource")} + {t("footer.tagline")} diff --git a/src/dal/private/contact.ts b/src/dal/private/contact.ts new file mode 100644 index 00000000..532baa79 --- /dev/null +++ b/src/dal/private/contact.ts @@ -0,0 +1,32 @@ +import {AxiosRequestConfig} from "axios"; +import {privateDalDriver} from "./private-dal-driver"; + +export async function fetchContactMessages(config?: AxiosRequestConfig) { + const response = await privateDalDriver.get("dashboard/contact-us", config); + return response.data; +} + +export async function fetchContactMessage( + uuid: string, + config?: AxiosRequestConfig, +) { + const response = await privateDalDriver.get( + `dashboard/contact-us/${uuid}`, + config, + ); + return response.data; +} + +export async function deleteContactMessage(uuid: string) { + return await privateDalDriver.delete(`dashboard/contact-us/${uuid}`); +} + +// The read timestamp itself is stamped by the backend; the caller only says +// which way the toggle went. +export async function markContactMessageAsRead(uuid: string, read: boolean) { + const response = await privateDalDriver.put( + `dashboard/contact-us/${uuid}/read`, + {read}, + ); + return response.data; +} diff --git a/src/dal/public/contact.ts b/src/dal/public/contact.ts new file mode 100644 index 00000000..089373da --- /dev/null +++ b/src/dal/public/contact.ts @@ -0,0 +1,13 @@ +import {publicDalDriver} from "./public-dal-driver"; + +// "Contact us" messages come from visitors, logged in or not, so they go through +// the public driver — the backend requires no authentication for them. +export async function sendContactMessage(body: { + subject: string; + body: string; + email: string; + phone: string; +}) { + const response = await publicDalDriver.post("contact-us", body); + return response.data; +} diff --git a/src/features/contact/actions/delete-message.ts b/src/features/contact/actions/delete-message.ts new file mode 100644 index 00000000..78644941 --- /dev/null +++ b/src/features/contact/actions/delete-message.ts @@ -0,0 +1,24 @@ +"use server"; + +import {revalidatePath} from "next/cache"; +import {unstable_rethrow} from "next/navigation"; +import {APP_PATHS} from "@/lib/app-paths"; +import {deleteContactMessage} from "@/dal/private/contact"; + +export async function deleteContactMessageAction( + prevState: boolean, + formData: FormData, +): Promise { + const uuid = formData.get("id")?.toString(); + if (uuid === undefined) { + return false; + } + try { + await deleteContactMessage(uuid); + revalidatePath(APP_PATHS.dashboard.contactUs.index); + return true; + } catch (error) { + unstable_rethrow(error); + return false; + } +} diff --git a/src/features/contact/actions/mark-as-read.ts b/src/features/contact/actions/mark-as-read.ts new file mode 100644 index 00000000..238603ce --- /dev/null +++ b/src/features/contact/actions/mark-as-read.ts @@ -0,0 +1,24 @@ +"use server"; + +import {revalidatePath} from "next/cache"; +import {unstable_rethrow} from "next/navigation"; +import {APP_PATHS} from "@/lib/app-paths"; +import {markContactMessageAsRead} from "@/dal/private/contact"; + +// Takes the state to move to, rather than flipping whatever the client last saw: +// a toggle computed against a stale value would write the wrong one. The read +// timestamp itself is the backend's to stamp. +export async function markContactMessageAsReadAction( + uuid: string, + read: boolean, +): Promise { + try { + await markContactMessageAsRead(uuid, read); + revalidatePath(APP_PATHS.dashboard.contactUs.index); + revalidatePath(APP_PATHS.dashboard.contactUs.detail(uuid)); + return true; + } catch (error) { + unstable_rethrow(error); + return false; + } +} diff --git a/src/features/contact/actions/send-message.ts b/src/features/contact/actions/send-message.ts new file mode 100644 index 00000000..45b1911e --- /dev/null +++ b/src/features/contact/actions/send-message.ts @@ -0,0 +1,37 @@ +"use server"; + +import {sendContactMessage} from "@/dal/public/contact"; +import { + captureFormValues, + extractValidationErrors, + type FormValues, + type ValidationErrorMap, +} from "@/lib/api/validation-errors"; + +type FormState = { + success?: boolean; + errors?: ValidationErrorMap; + values?: FormValues; +}; + +export async function sendContactMessageAction( + state: FormState, + formData: FormData, +): Promise { + const subject = formData.get("subject")?.toString() ?? ""; + const body = formData.get("body")?.toString() ?? ""; + const email = formData.get("email")?.toString() ?? ""; + const phone = formData.get("phone")?.toString() ?? ""; + + try { + await sendContactMessage({subject, body, email, phone}); + return {success: true}; + } catch (err) { + const echoed = captureFormValues(formData); + const errors = extractValidationErrors(err); + if (errors) { + return {success: false, errors, values: echoed}; + } + return {success: false, values: echoed}; + } +} diff --git a/src/features/contact/components/contact-form/contact-form.tsx b/src/features/contact/components/contact-form/contact-form.tsx new file mode 100644 index 00000000..8e06a7c3 --- /dev/null +++ b/src/features/contact/components/contact-form/contact-form.tsx @@ -0,0 +1,110 @@ +"use client"; + +import {useActionState} from "react"; +import { + Alert, + Box, + Button, + Paper, + Stack, + Text, + TextInput, + Textarea, + Title, +} from "@mantine/core"; +import {IconCircleCheck} from "@tabler/icons-react"; +import {ValidationErrorsAlert} from "@/components/errors/validation-errors-alert"; +import {nonFieldErrors} from "@/lib/api/validation-errors"; +import {useTranslations} from "@/i18n/provider"; +import {sendContactMessageAction} from "../../actions/send-message"; + +const CONTACT_FIELDS = ["subject", "body", "email", "phone"] as const; + +export function ContactForm() { + const t = useTranslations(); + const [state, dispatch, isPending] = useActionState( + sendContactMessageAction, + {}, + ); + + const formErrors = nonFieldErrors(state.errors, CONTACT_FIELDS); + + if (state.success === true) { + return ( + + } + > + {t("contactUs.form.success")} + + + ); + } + + return ( + + {t("contactUs.form.title")} + + {t("contactUs.form.description")} + + + + +