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")}
+
+
+
+
+
+ {/* Neither field is required on its own, but one of the two is — the
+ backend is the one that decides, and says so per field. */}
+
+
+ {state.success === false && (
+ 0
+ ? formErrors
+ : state.errors
+ ? []
+ : [t("contactUs.form.genericError")]
+ }
+ title={t("contactUs.form.failedTitle")}
+ />
+ )}
+
+
+
+
+ );
+}
diff --git a/src/features/contact/components/contact-form/index.ts b/src/features/contact/components/contact-form/index.ts
new file mode 100644
index 00000000..21252043
--- /dev/null
+++ b/src/features/contact/components/contact-form/index.ts
@@ -0,0 +1 @@
+export {ContactForm} from "./contact-form";
diff --git a/src/features/contact/components/message-detail/index.ts b/src/features/contact/components/message-detail/index.ts
new file mode 100644
index 00000000..5b68e703
--- /dev/null
+++ b/src/features/contact/components/message-detail/index.ts
@@ -0,0 +1 @@
+export {ContactMessageDetail} from "./message-detail";
diff --git a/src/features/contact/components/message-detail/message-detail.tsx b/src/features/contact/components/message-detail/message-detail.tsx
new file mode 100644
index 00000000..f6230e73
--- /dev/null
+++ b/src/features/contact/components/message-detail/message-detail.tsx
@@ -0,0 +1,92 @@
+import {
+ Anchor,
+ Badge,
+ Divider,
+ Group,
+ Paper,
+ Stack,
+ Text,
+ Title,
+} from "@mantine/core";
+import {PermissionGuard} from "@/components/permission-guard";
+import {formatDate, isGregorianStartDateTime} from "@/lib/date-and-time";
+import {getServerDictionary} from "@/i18n/server";
+import {ReadToggle} from "../messages-table/read-toggle";
+
+type Props = {
+ message: {
+ uuid: string;
+ subject: string;
+ body: string;
+ email?: string;
+ phone?: string;
+ read_at: string;
+ created_at: string;
+ };
+};
+
+export async function ContactMessageDetail({message}: Props) {
+ const {t} = await getServerDictionary();
+ const isRead = !isGregorianStartDateTime(message.read_at);
+
+ return (
+
+
+ {message.subject}
+
+
+
+
+
+ {isRead ? t("contactUs.status.read") : t("contactUs.status.unread")}
+
+
+
+
+
+ {message.body}
+
+
+
+
+
+ {Boolean(message.email) && (
+
+
+ {t("contactUs.detail.email")}
+
+
+ {message.email}
+
+
+ )}
+ {Boolean(message.phone) && (
+
+
+ {t("contactUs.detail.phone")}
+
+
+ {message.phone}
+
+
+ )}
+
+
+ {t("contactUs.detail.receivedAt")}
+
+ {formatDate(message.created_at)}
+
+
+
+ {t("contactUs.detail.readAt")}
+
+
+ {isRead
+ ? formatDate(message.read_at)
+ : t("contactUs.status.unread")}
+
+
+
+
+ );
+}
diff --git a/src/features/contact/components/messages-table/delete-button.tsx b/src/features/contact/components/messages-table/delete-button.tsx
new file mode 100644
index 00000000..3357795c
--- /dev/null
+++ b/src/features/contact/components/messages-table/delete-button.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import {useState, useActionState} from "react";
+import {
+ ActionIcon,
+ Button,
+ Group,
+ Modal,
+ Text,
+ Tooltip,
+ rem,
+} from "@mantine/core";
+import {IconTrash} from "@tabler/icons-react";
+import {useTranslations} from "@/i18n/provider";
+import {deleteContactMessageAction} from "../../actions/delete-message";
+
+type Props = {
+ uuid: string;
+ subject?: string;
+};
+
+export function MessageDeleteButton({uuid, subject}: Props) {
+ const t = useTranslations();
+ const [, formAction, isPending] = useActionState(
+ deleteContactMessageAction,
+ false,
+ );
+ const [isConfirmOpen, setIsConfirmOpen] = useState(false);
+
+ return (
+ <>
+
+ {
+ setIsConfirmOpen(true);
+ }}
+ >
+
+
+
+ {
+ setIsConfirmOpen(false);
+ }}
+ >
+
+ {t("contactUs.table.deleteConfirm", {subject: subject ?? ""})}
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/features/contact/components/messages-table/index.ts b/src/features/contact/components/messages-table/index.ts
new file mode 100644
index 00000000..9ad10966
--- /dev/null
+++ b/src/features/contact/components/messages-table/index.ts
@@ -0,0 +1,2 @@
+export {ContactMessagesTable} from "./table";
+export {ContactMessagesTableSkeleton} from "./table-skeleton";
diff --git a/src/features/contact/components/messages-table/read-toggle.tsx b/src/features/contact/components/messages-table/read-toggle.tsx
new file mode 100644
index 00000000..0e8709f5
--- /dev/null
+++ b/src/features/contact/components/messages-table/read-toggle.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import {useOptimistic, useTransition} from "react";
+import {Switch, Tooltip} from "@mantine/core";
+import {useTranslations} from "@/i18n/provider";
+import {markContactMessageAsReadAction} from "../../actions/mark-as-read";
+
+type Props = {
+ uuid: string;
+ isRead: boolean;
+};
+
+// The read flag is a toggle rather than a one-way "mark as read", so a message
+// opened by mistake can go back to the unread pile.
+//
+// What the switch shows is always the server's `isRead`; `useOptimistic` only
+// covers the round trip, and the revalidated row takes over the moment it lands.
+// Holding the flag in client state instead lets the switch and the row it sits
+// in drift apart, since only one of the two would see the new data.
+export function ReadToggle({uuid, isRead}: Props) {
+ const t = useTranslations();
+ const [isPending, startTransition] = useTransition();
+ const [optimisticRead, setOptimisticRead] = useOptimistic(isRead);
+
+ const label = optimisticRead
+ ? t("contactUs.table.markAsUnread")
+ : t("contactUs.table.markAsRead");
+
+ return (
+
+ {
+ const read = event.currentTarget.checked;
+ startTransition(async () => {
+ setOptimisticRead(read);
+ await markContactMessageAsReadAction(uuid, read);
+ });
+ }}
+ />
+
+ );
+}
diff --git a/src/features/contact/components/messages-table/table-skeleton.tsx b/src/features/contact/components/messages-table/table-skeleton.tsx
new file mode 100644
index 00000000..91ebde44
--- /dev/null
+++ b/src/features/contact/components/messages-table/table-skeleton.tsx
@@ -0,0 +1,13 @@
+import {TABLE_HEADERS} from "./table";
+import {TableSkeleton} from "@/components/skeletons/table";
+
+export function ContactMessagesTableSkeleton() {
+ return (
+
+ );
+}
diff --git a/src/features/contact/components/messages-table/table.tsx b/src/features/contact/components/messages-table/table.tsx
new file mode 100644
index 00000000..b568c7c4
--- /dev/null
+++ b/src/features/contact/components/messages-table/table.tsx
@@ -0,0 +1,152 @@
+import Link from "@/components/link";
+import {
+ ActionIcon,
+ ActionIconGroup,
+ Badge,
+ Group,
+ Stack,
+ Table,
+ TableScrollContainer,
+ TableTbody,
+ TableTd,
+ TableTh,
+ TableThead,
+ TableTr,
+ Text,
+ Tooltip,
+ rem,
+} from "@mantine/core";
+import {IconEye} from "@tabler/icons-react";
+import {PermissionGuard} from "@/components/permission-guard";
+import {Pagination} from "@/components/pagination";
+import {fetchContactMessages} from "@/dal/private/contact";
+import {formatDate, isGregorianStartDateTime} from "@/lib/date-and-time";
+import {APP_PATHS} from "@/lib/app-paths";
+import {getServerDictionary} from "@/i18n/server";
+import {MessageDeleteButton} from "./delete-button";
+import {ReadToggle} from "./read-toggle";
+
+export const TABLE_HEADERS = [
+ "#",
+ "contactUs.table.headerSubject",
+ "contactUs.table.headerSender",
+ "contactUs.table.headerReceivedDate",
+ "contactUs.table.headerRead",
+ "common.actions",
+];
+
+type Props = {
+ page: number | string;
+};
+
+export async function ContactMessagesTable({page}: Props) {
+ const {t} = await getServerDictionary();
+ const messagesResponse = await fetchContactMessages({
+ params: {
+ page: page,
+ },
+ });
+ const messages = messagesResponse.items;
+ const {total_pages, current_page} = messagesResponse.pagination;
+
+ return (
+ <>
+
+
+
+
+ {TABLE_HEADERS.map((h) => {
+ return {t(h)};
+ })}
+
+
+
+ {messages.length === 0 && (
+
+
+ {t("contactUs.table.empty")}
+
+
+ )}
+ {messages.map((message: any, index: number) => {
+ const isRead = !isGregorianStartDateTime(message.read_at);
+
+ return (
+
+ {index + 1}
+ {message.subject}
+
+ {/* The sender is anonymous — whichever way they left to be
+ reached is all we have to identify them by. */}
+
+ {Boolean(message.email) && (
+ {message.email}
+ )}
+ {Boolean(message.phone) && (
+
+ {message.phone}
+
+ )}
+
+
+ {formatDate(message.created_at)}
+
+
+
+
+
+ {isRead ? (
+
+ {formatDate(message.read_at)}
+
+ ) : (
+
+ {t("contactUs.status.unread")}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ })}
+
+
+
+ {messages.length >= 1 && (
+
+
+
+ )}
+ >
+ );
+}
diff --git a/src/features/dashboard-layout/components/layout-sidebar.tsx b/src/features/dashboard-layout/components/layout-sidebar.tsx
index cbf5501b..1597dc47 100644
--- a/src/features/dashboard-layout/components/layout-sidebar.tsx
+++ b/src/features/dashboard-layout/components/layout-sidebar.tsx
@@ -16,6 +16,7 @@ import {
IconKey,
IconUser,
IconLanguage,
+ IconMail,
IconPictureInPicture,
} from "@tabler/icons-react";
import {hasPermission} from "@/lib/auth/shared";
@@ -79,6 +80,12 @@ const SIDE_BAR_DATA: SidebarSchema[] = [
href: dashboard.my.bookmarks,
requiredPermissions: ["self.bookmarks.index"],
},
+ {
+ labelKey: "dashboard.sidebar.contactUs",
+ icon: IconMail,
+ href: dashboard.contactUs.index,
+ requiredPermissions: ["contactus.index"],
+ },
{
labelKey: "dashboard.sidebar.users",
icon: IconUsers,
diff --git a/src/i18n/dictionaries/en.json b/src/i18n/dictionaries/en.json
index 749a592d..69337bd6 100644
--- a/src/i18n/dictionaries/en.json
+++ b/src/i18n/dictionaries/en.json
@@ -52,7 +52,8 @@
},
"footer": {
"tagline": "Tarhche | A fresh design",
- "openSource": "Open source"
+ "openSource": "Open source",
+ "contactUs": "Contact us"
},
"home": {
"title": "Home",
@@ -154,6 +155,51 @@
"editTitle": "Edit comment"
}
},
+ "contactUs": {
+ "form": {
+ "title": "Contact us",
+ "description": "Send us a message and we will get back to you.",
+ "subjectLabel": "Subject",
+ "subjectPlaceholder": "What is this about?",
+ "bodyLabel": "Message",
+ "bodyPlaceholder": "Write your message here",
+ "emailLabel": "Email",
+ "phoneLabel": "Phone number",
+ "phonePlaceholder": "09123456789",
+ "contactHint": "Leave an email address or a phone number so we can reply.",
+ "submit": "Send message",
+ "successTitle": "Message sent",
+ "success": "Thank you! Your message has been received and we will get back to you soon.",
+ "failedTitle": "Failed to send the message",
+ "genericError": "Unfortunately an error occurred while sending your message. Please try again."
+ },
+ "dashboard": {
+ "title": "Contact-us messages",
+ "detailTitle": "Message"
+ },
+ "table": {
+ "headerSubject": "Subject",
+ "headerSender": "Sender",
+ "headerReceivedDate": "Received date",
+ "headerRead": "Read",
+ "empty": "There are no messages",
+ "view": "View message",
+ "delete": "Delete message",
+ "deleteConfirm": "Are you sure you want to delete \"{subject}\"?",
+ "markAsRead": "Mark as read",
+ "markAsUnread": "Mark as unread"
+ },
+ "status": {
+ "read": "Read",
+ "unread": "Unread"
+ },
+ "detail": {
+ "email": "Email:",
+ "phone": "Phone number:",
+ "receivedAt": "Received at:",
+ "readAt": "Read at:"
+ }
+ },
"bookmarks": {
"button": {
"remove": "Remove from bookmarks",
@@ -312,7 +358,8 @@
"languages": "Languages",
"settings": "Settings",
"profile": "Profile",
- "logout": "Logout"
+ "logout": "Logout",
+ "contactUs": "Contact us"
}
},
"users": {
diff --git a/src/i18n/dictionaries/fa.json b/src/i18n/dictionaries/fa.json
index 8dd5ad94..f437fb9c 100644
--- a/src/i18n/dictionaries/fa.json
+++ b/src/i18n/dictionaries/fa.json
@@ -52,7 +52,8 @@
},
"footer": {
"tagline": "طرحچه | طرحی نو در اندازیم",
- "openSource": "اوپن سورس"
+ "openSource": "اوپن سورس",
+ "contactUs": "تماس با ما"
},
"home": {
"title": "خانه",
@@ -154,6 +155,51 @@
"editTitle": "ویرایش کامنت"
}
},
+ "contactUs": {
+ "form": {
+ "title": "تماس با ما",
+ "description": "پیام خود را برای ما بفرستید، در اولین فرصت پاسخ میدهیم.",
+ "subjectLabel": "موضوع",
+ "subjectPlaceholder": "موضوع پیام شما چیست؟",
+ "bodyLabel": "متن پیام",
+ "bodyPlaceholder": "متن پیام خود را اینجا بنویسید",
+ "emailLabel": "ایمیل",
+ "phoneLabel": "شماره تماس",
+ "phonePlaceholder": "۰۹۱۲۳۴۵۶۷۸۹",
+ "contactHint": "ایمیل یا شماره تماس خود را وارد کنید تا بتوانیم پاسخ دهیم.",
+ "submit": "ارسال پیام",
+ "successTitle": "پیام ارسال شد",
+ "success": "با تشکر! پیام شما دریافت شد و به زودی با شما تماس میگیریم.",
+ "failedTitle": "ارسال پیام ناموفق بود",
+ "genericError": "متاسفانه در ارسال پیام شما خطایی رخ داد. لطفا دوباره تلاش کنید."
+ },
+ "dashboard": {
+ "title": "پیام های تماس با ما",
+ "detailTitle": "پیام"
+ },
+ "table": {
+ "headerSubject": "موضوع",
+ "headerSender": "فرستنده",
+ "headerReceivedDate": "تاریخ دریافت",
+ "headerRead": "خوانده شده",
+ "empty": "پیامی وجود ندارد",
+ "view": "مشاهده پیام",
+ "delete": "حذف پیام",
+ "deleteConfirm": "آیا از حذف «{subject}» مطمئن هستید؟",
+ "markAsRead": "علامت گذاری به عنوان خوانده شده",
+ "markAsUnread": "علامت گذاری به عنوان خوانده نشده"
+ },
+ "status": {
+ "read": "خوانده شده",
+ "unread": "خوانده نشده"
+ },
+ "detail": {
+ "email": "ایمیل:",
+ "phone": "شماره تماس:",
+ "receivedAt": "تاریخ دریافت:",
+ "readAt": "تاریخ خواندن:"
+ }
+ },
"bookmarks": {
"button": {
"remove": "حذف از بوکمارک ها",
@@ -312,7 +358,8 @@
"languages": "زبان ها",
"settings": "تنظیمات",
"profile": "پروفایل",
- "logout": "خروج"
+ "logout": "خروج",
+ "contactUs": "تماس با ما"
}
},
"users": {
diff --git a/src/lib/app-paths.ts b/src/lib/app-paths.ts
index afa06bf8..6a131c12 100644
--- a/src/lib/app-paths.ts
+++ b/src/lib/app-paths.ts
@@ -14,6 +14,7 @@ export const APP_PATHS = {
hashtags: {
index: "/hashtags",
},
+ contactUs: "/contact-us",
dashboard: {
index: "/dashboard",
articles: {
@@ -46,6 +47,10 @@ export const APP_PATHS = {
new: "/dashboard/languages/new",
edit: (code: string) => `/dashboard/languages/${code}`,
},
+ contactUs: {
+ index: "/dashboard/contact-us",
+ detail: (uuid: string) => `/dashboard/contact-us/${uuid}`,
+ },
files: "/dashboard/files",
settings: "/dashboard/settings",
profile: {
diff --git a/src/lib/app-permissions.ts b/src/lib/app-permissions.ts
index 42a3a32a..e8d447cd 100644
--- a/src/lib/app-permissions.ts
+++ b/src/lib/app-permissions.ts
@@ -29,6 +29,12 @@ export const PERMISSIONS = {
SHOW: "config.show",
UPDATE: "config.update",
},
+ contactus: {
+ DELETE: "contactus.delete",
+ INDEX: "contactus.index",
+ SHOW: "contactus.show",
+ MARK_AS_READ: "contactus.markAsRead",
+ },
elements: {
CREATE: "elements.create",
DELETE: "elements.delete",