Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/app/(dashboard)/dashboard/contact-us/[uuid]/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<DashboardBreadcrumbs
crumbs={[
{
label: t("contactUs.dashboard.title"),
href: APP_PATHS.dashboard.contactUs.index,
},
{
label: t("contactUs.dashboard.detailTitle"),
},
]}
/>
<Box mt="md">{children}</Box>
</>
);
}

export default ContactMessageLayout;
36 changes: 36 additions & 0 deletions src/app/(dashboard)/dashboard/contact-us/[uuid]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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 <ContactMessageDetail message={message} />;
}

export default withPermissions(ContactMessagePage, {
requiredPermissions: ["contactus.show"],
});
52 changes: 52 additions & 0 deletions src/app/(dashboard)/dashboard/contact-us/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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 (
<Box>
<DashboardBreadcrumbs
crumbs={[
{
label: t("contactUs.dashboard.title"),
href: APP_PATHS.dashboard.contactUs.index,
},
]}
/>
<Box mt={"md"}>
<Suspense key={page} fallback={<ContactMessagesTableSkeleton />}>
<ContactMessagesTable page={page ?? 1} />
</Suspense>
</Box>
</Box>
);
}

export default withPermissions(ContactUsPage, {
requiredPermissions: ["contactus.index"],
});
38 changes: 38 additions & 0 deletions src/app/(public)/[lang]/contact-us/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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.
<Container
component="section"
px={{base: "0", sm: "md"}}
size="sm"
mt="xl"
mb="xl"
>
<ContactForm />
</Container>
);
}

export default ContactUsPage;
75 changes: 45 additions & 30 deletions src/app/(public)/footer.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box
component="footer"
py="lg"
px="0"
mt="xl"
style={{borderTop: "1px solid var(--mantine-color-gray-3)"}}
>
<Group justify="space-between" wrap="wrap">
<Anchor
href="https://tarhche.com"
c="gray.7"
style={{
textDecoration: "none",
display: "flex",
alignItems: "center",
gap: "0.1rem",
}}
>
<Text span size="sm">
{t("footer.tagline")}
</Text>
</Anchor>
{/* 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. */}
<Group justify="space-between" wrap="wrap" gap="md">
<Group wrap="wrap" gap="lg">
<Anchor
component={Link}
href={APP_PATHS.contactUs}
c="gray.7"
style={linkStyle}
>
<IconMail size="1.3rem" stroke={1.5} />
<Text span size="sm">
{t("footer.contactUs")}
</Text>
</Anchor>

<Anchor
target="_blank"
rel="noopener noreferrer"
href="https://github.com/Tarhche"
c="gray.7"
style={{
textDecoration: "none",
display: "flex",
alignItems: "center",
gap: "0.1rem",
}}
>
<IconBrandGithub size="2rem" />
<Anchor
target="_blank"
rel="noopener noreferrer"
href="https://github.com/Tarhche"
c="gray.7"
style={linkStyle}
>
<IconBrandGithub size="1.6rem" stroke={1.5} />
<Text span size="sm">
{t("footer.openSource")}
</Text>
</Anchor>
</Group>

<Anchor href="https://tarhche.com" c="gray.7" style={linkStyle}>
<Text span size="sm">
{t("footer.openSource")}
{t("footer.tagline")}
</Text>
</Anchor>
</Group>
Expand Down
32 changes: 32 additions & 0 deletions src/dal/private/contact.ts
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 13 additions & 0 deletions src/dal/public/contact.ts
Original file line number Diff line number Diff line change
@@ -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;
}
24 changes: 24 additions & 0 deletions src/features/contact/actions/delete-message.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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;
}
}
24 changes: 24 additions & 0 deletions src/features/contact/actions/mark-as-read.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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;
}
}
37 changes: 37 additions & 0 deletions src/features/contact/actions/send-message.ts
Original file line number Diff line number Diff line change
@@ -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<FormState> {
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};
}
}
Loading
Loading