feat(db): mesh data model — meshes, members, invites, audit log
- pgSchema "mesh" with 4 tables isolating the peer mesh domain - Enums: visibility, transport, tier, role - audit_log is metadata-only (E2E encryption enforced at broker/client) - Cascade on mesh delete, soft-delete via archivedAt/revokedAt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
27
apps/web/src/modules/marketing/contact/actions/index.ts
Normal file
27
apps/web/src/modules/marketing/contact/actions/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
"use server";
|
||||
|
||||
import env from "env.config";
|
||||
|
||||
import { EmailTemplate } from "@turbostarter/email";
|
||||
import { sendEmail } from "@turbostarter/email/server";
|
||||
import { getTranslation } from "@turbostarter/i18n/server";
|
||||
|
||||
import type { ContactFormPayload } from "../utils/schema";
|
||||
|
||||
export const sendContactForm = async (data: ContactFormPayload) => {
|
||||
try {
|
||||
await sendEmail({
|
||||
to: env.CONTACT_EMAIL,
|
||||
template: EmailTemplate.CONTACT_FORM,
|
||||
variables: data,
|
||||
});
|
||||
return { error: null };
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
return { error: e.message };
|
||||
}
|
||||
|
||||
const { t } = await getTranslation({ ns: "common" });
|
||||
return { error: t("error.general") };
|
||||
}
|
||||
};
|
||||
136
apps/web/src/modules/marketing/contact/contact-form.tsx
Normal file
136
apps/web/src/modules/marketing/contact/contact-form.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Card, CardContent } from "@turbostarter/ui-web/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Input } from "@turbostarter/ui-web/input";
|
||||
import { Textarea } from "@turbostarter/ui-web/textarea";
|
||||
|
||||
import { sendContactForm } from "./actions";
|
||||
import { contactFormSchema, MAX_MESSAGE_LENGTH } from "./utils/schema";
|
||||
|
||||
import type { ContactFormPayload } from "./utils/schema";
|
||||
|
||||
export function ContactForm() {
|
||||
const { t } = useTranslation(["common", "marketing"]);
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(contactFormSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
email: "",
|
||||
message: "",
|
||||
},
|
||||
});
|
||||
|
||||
const message = form.watch("message");
|
||||
|
||||
const onSubmit = async (data: ContactFormPayload) => {
|
||||
const { error } = await sendContactForm(data);
|
||||
|
||||
if (error) {
|
||||
return toast.error(error);
|
||||
}
|
||||
|
||||
toast.success(t("contact.form.success.title"), {
|
||||
description: t("contact.form.success.description"),
|
||||
});
|
||||
|
||||
form.reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-lg border-none bg-transparent shadow-none">
|
||||
<CardContent className="w-full p-0">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("contact.form.name.placeholder")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("email")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("contact.form.email.placeholder")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="message"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="mt-2 flex justify-between">
|
||||
<span>{t("message")}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{message.length}/{MAX_MESSAGE_LENGTH}
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t("contact.form.message.placeholder")}
|
||||
className="min-h-[120px]"
|
||||
maxLength={MAX_MESSAGE_LENGTH}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-2"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Icons.Loader2 className="size-5 animate-spin" />
|
||||
) : (
|
||||
t("contact.form.submit")
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
11
apps/web/src/modules/marketing/contact/utils/schema.ts
Normal file
11
apps/web/src/modules/marketing/contact/utils/schema.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import * as z from "zod";
|
||||
|
||||
export const MAX_MESSAGE_LENGTH = 4000;
|
||||
|
||||
export const contactFormSchema = z.object({
|
||||
name: z.string().min(2),
|
||||
email: z.email().max(254),
|
||||
message: z.string().min(10).max(MAX_MESSAGE_LENGTH),
|
||||
});
|
||||
|
||||
export type ContactFormPayload = z.infer<typeof contactFormSchema>;
|
||||
Reference in New Issue
Block a user