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")} + + + + +