refactor: update company registration flow and email handling; switch to zod for validation

This commit is contained in:
Danel Kungla
2025-06-17 13:45:46 +03:00
parent fe44030190
commit 291919c2d1
18 changed files with 274 additions and 87 deletions

View File

@@ -1,18 +1,23 @@
"use client"; 'use client';
import { MedReportTitle } from "@/components/med-report-title"; import React from 'react';
import React from "react";
import { yupResolver } from "@hookform/resolvers/yup"; import { useRouter } from 'next/navigation';
import { useForm } from "react-hook-form";
import { companySchema } from "@/lib/validations/companySchema"; import { MedReportTitle } from '@/components/med-report-title';
import { CompanySubmitData } from "@/lib/types/company"; import { SubmitButton } from '@/components/ui/submit-button';
import { submitCompanyRegistration } from "@/lib/services/register-company.service"; import { sendCompanyOfferEmail } from '@/lib/services/mailer.service';
import { useRouter } from "next/navigation"; import { submitCompanyRegistration } from '@/lib/services/register-company.service';
import { Label } from "@kit/ui/label"; import { CompanySubmitData } from '@/lib/types/company';
import { Input } from "@kit/ui/input"; import { companySchema } from '@/lib/validations/companySchema';
import { SubmitButton } from "@/components/ui/submit-button"; import { zodResolver } from '@hookform/resolvers/zod';
import { FormItem } from "@kit/ui/form"; import { useForm } from 'react-hook-form';
import { Trans } from "@kit/ui/trans"; import { useTranslation } from 'react-i18next';
import { FormItem } from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { Trans } from '@kit/ui/trans';
export default function RegisterCompany() { export default function RegisterCompany() {
const router = useRouter(); const router = useRouter();
@@ -21,9 +26,10 @@ export default function RegisterCompany() {
handleSubmit, handleSubmit,
formState: { errors, isValid, isSubmitting }, formState: { errors, isValid, isSubmitting },
} = useForm({ } = useForm({
resolver: yupResolver(companySchema), resolver: zodResolver(companySchema),
mode: "onChange", mode: 'onChange',
}); });
const language = useTranslation().i18n.language;
async function onSubmit(data: CompanySubmitData) { async function onSubmit(data: CompanySubmitData) {
const formData = new FormData(); const formData = new FormData();
@@ -33,57 +39,66 @@ export default function RegisterCompany() {
try { try {
await submitCompanyRegistration(formData); await submitCompanyRegistration(formData);
router.push("/register-company/success"); sendCompanyOfferEmail(data, language)
.then(() => router.push('/register-company/success'))
.catch((error) => alert('error: ' + error));
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof Error) { if (err instanceof Error) {
alert("Server validation error: " + err.message); alert('Server validation error: ' + err.message);
} }
alert("Server validation error"); alert('Server validation error');
} }
} }
return ( return (
<div className="flex flex-row border rounded-3xl border-border max-w-5xl overflow-hidden"> <div className="border-border flex max-w-5xl flex-row overflow-hidden rounded-3xl border">
<div className="flex flex-col text-center py-14 px-12 w-1/2"> <div className="flex w-1/2 flex-col px-12 py-14 text-center">
<MedReportTitle /> <MedReportTitle />
<h1 className="pt-8">Ettevõtte andmed</h1> <h1 className="pt-8">
<p className="pt-2 text-muted-foreground text-sm"> <Trans i18nKey={'account:requestCompanyAccount:title'} />
Pakkumise saamiseks palun sisesta ettevõtte andmed millega MedReport </h1>
kasutada kavatsed. <p className="text-muted-foreground pt-2 text-sm">
<Trans i18nKey={'account:requestCompanyAccount:description'} />
</p> </p>
<form <form
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
noValidate noValidate
className="flex gap-6 flex-col text-left pt-8 px-6" className="flex flex-col gap-6 px-6 pt-8 text-left"
> >
<FormItem> <FormItem>
<Label>Ettevõtte nimi</Label> <Label>
<Input {...register("companyName")} /> <Trans i18nKey={'common:formField:companyName'} />
</Label>
<Input {...register('companyName')} />
</FormItem> </FormItem>
<FormItem> <FormItem>
<Label>Kontaktisik</Label> <Label>
<Input {...register("contactPerson")} /> <Trans i18nKey={'common:formField:contactPerson'} />
</Label>
<Input {...register('contactPerson')} />
</FormItem> </FormItem>
<FormItem> <FormItem>
<Label>E-mail</Label> <Label>
<Input type="email" {...register("email")}></Input> <Trans i18nKey={'common:formField:email'} />
</Label>
<Input type="email" {...register('email')}></Input>
</FormItem> </FormItem>
<FormItem> <FormItem>
<Label>Telefon</Label> <Label>
<Input type="tel" {...register("phone")} /> <Trans i18nKey={'common:formField:phone'} />
</Label>
<Input type="tel" {...register('phone')} />
</FormItem> </FormItem>
<SubmitButton <SubmitButton
disabled={!isValid || isSubmitting} disabled={!isValid || isSubmitting}
pendingText="Saatmine..." pendingText="Saatmine..."
type="submit" type="submit"
formAction={submitCompanyRegistration}
> >
<Trans i18nKey={'account:requestCompanyAccount'} /> <Trans i18nKey={'account:requestCompanyAccount:button'} />
</SubmitButton> </SubmitButton>
</form> </form>
</div> </div>
<div className="w-1/2 min-w-[460px] bg-[url(/assets/med-report-logo-big.png)] bg-cover bg-center bg-no-repeat"> <div className="w-1/2 min-w-[460px] bg-[url(/assets/med-report-logo-big.png)] bg-cover bg-center bg-no-repeat"></div>
</div>
</div> </div>
); );
} }

View File

@@ -1,10 +1,14 @@
import { MedReportTitle } from "@/components/med-report-title"; import Image from 'next/image';
import Image from "next/image"; import Link from 'next/link';
import Link from "next/link";
import { MedReportTitle } from '@/components/med-report-title';
import { Button } from '@/packages/ui/src/shadcn/button';
import { Trans } from '@kit/ui/trans';
export default function CompanyRegistrationSuccess() { export default function CompanyRegistrationSuccess() {
return ( return (
<div className="pt-2 px-16 pb-12 border rounded-3xl border-border"> <div className="border-border rounded-3xl border px-16 pt-4 pb-12">
<MedReportTitle /> <MedReportTitle />
<div className="flex flex-col items-center px-4"> <div className="flex flex-col items-center px-4">
<Image <Image
@@ -14,11 +18,17 @@ export default function CompanyRegistrationSuccess() {
width={326} width={326}
height={195} height={195}
/> />
<h1 className="pb-2">Päring edukalt saadetud!</h1> <h1 className="pb-2">
<p className=" text-muted-foreground text-sm">Saadame teile esimesel võimalusel vastuse</p> <Trans i18nKey={'account:requestCompanyAccount:successTitle'} />
</h1>
<p className="text-muted-foreground text-sm">
<Trans i18nKey={'account:requestCompanyAccount:successDescription'} />
</p>
</div> </div>
<Button className="w-full mt-8"> <Button className="mt-8 w-full">
<Link href="/">Tagasi kodulehele</Link> <Link href="/">
<Trans i18nKey={'account:requestCompanyAccount:successButton'} />
</Link>
</Button> </Button>
</div> </div>
); );

View File

@@ -32,4 +32,4 @@ export const setUser = noop('Sentry.setUser');
export const loadStripe = noop('Stripe.loadStripe'); export const loadStripe = noop('Stripe.loadStripe');
// Nodemailer // Nodemailer
export const createTransport = noop('Nodemailer.createTransport'); // export const createTransport = noop('Nodemailer.createTransport');

View File

@@ -0,0 +1,42 @@
'use server';
import { getMailer } from '@kit/mailers';
import { enhanceAction } from '@kit/next/actions';
import { CompanySubmitData } from '../types/company';
import { emailSchema } from '../validations/email.schema';
export const sendCompanyOfferEmail = async (
data: CompanySubmitData,
language: string,
) => {
const { renderCompanyOfferEmail } = await import('@kit/email-templates');
const { html, subject, to } = await renderCompanyOfferEmail({
language,
companyData: data,
});
await sendEmail({
subject,
html,
to,
});
};
export const sendEmail = enhanceAction(
async ({ subject, html, to }) => {
const mailer = await getMailer();
await mailer.sendEmail({
to,
subject,
html,
});
return {};
},
{
schema: emailSchema,
auth: false,
},
);

View File

@@ -1,31 +1,26 @@
"use server"; 'use server';
import * as yup from "yup"; import { companySchema } from '@/lib/validations/companySchema';
import { companySchema } from "@/lib/validations/companySchema";
export async function submitCompanyRegistration(formData: FormData) { export async function submitCompanyRegistration(formData: FormData) {
const data = { const data = {
companyName: formData.get("companyName")?.toString() || "", companyName: formData.get('companyName')?.toString() || '',
contactPerson: formData.get("contactPerson")?.toString() || "", contactPerson: formData.get('contactPerson')?.toString() || '',
email: formData.get("email")?.toString() || "", email: formData.get('email')?.toString() || '',
phone: formData.get("phone")?.toString() || "", phone: formData.get('phone')?.toString() || '',
}; };
try { const result = companySchema.safeParse(data);
await companySchema.validate(data, { abortEarly: false });
console.log("Valid data:", data); if (!result.success) {
} catch (validationError) { const errors = result.error.errors.map((err) => ({
if (validationError instanceof yup.ValidationError) { path: err.path.join('.'),
const errors = validationError.inner.map((err) => ({ message: err.message,
path: err.path, }));
message: err.message,
})); throw new Error(
throw new Error( 'Validation failed: ' +
"Validation failed: " + errors.map((e) => `${e.path}: ${e.message}`).join(', '),
errors.map((e) => `${e.path}: ${e.message}`).join(", ") );
);
}
throw validationError;
} }
} }

View File

@@ -1,8 +1,16 @@
import * as yup from "yup"; import { z } from 'zod';
export const companySchema = yup.object({ export const companySchema = z.object({
companyName: yup.string().required("Company name is required"), companyName: z.string({
contactPerson: yup.string().required("Contact person is required"), required_error: 'Company name is required',
email: yup.string().email("Invalid email").required("Email is required"), }),
phone: yup.string().optional(), contactPerson: z.string({
required_error: 'Contact person is required',
}),
email: z
.string({
required_error: 'Email is required',
})
.email('Invalid email'),
phone: z.string().optional(),
}); });

View File

@@ -0,0 +1,7 @@
import { z } from 'zod';
export const emailSchema = z.object({
to: z.string().email(),
subject: z.string().min(1).max(200),
html: z.string().min(1).max(5000),
});

View File

@@ -129,7 +129,7 @@ function getModulesAliases() {
const excludeSentry = monitoringProvider !== 'sentry'; const excludeSentry = monitoringProvider !== 'sentry';
const excludeBaselime = monitoringProvider !== 'baselime'; const excludeBaselime = monitoringProvider !== 'baselime';
const excludeStripe = billingProvider !== 'stripe'; const excludeStripe = billingProvider !== 'stripe';
const excludeNodemailer = mailerProvider !== 'nodemailer'; // const excludeNodemailer = mailerProvider !== 'nodemailer';
const excludeTurnstile = !captchaProvider; const excludeTurnstile = !captchaProvider;
/** @type {Record<string, string>} */ /** @type {Record<string, string>} */
@@ -151,9 +151,9 @@ function getModulesAliases() {
aliases['@stripe/stripe-js'] = noopPath; aliases['@stripe/stripe-js'] = noopPath;
} }
if (excludeNodemailer) { // if (excludeNodemailer) {
aliases['nodemailer'] = noopPath; // aliases['nodemailer'] = noopPath;
} // }
if (excludeTurnstile) { if (excludeTurnstile) {
aliases['@marsidev/react-turnstile'] = noopPath; aliases['@marsidev/react-turnstile'] = noopPath;

View File

@@ -2,4 +2,4 @@
This package is responsible for managing email templates using the react.email library. This package is responsible for managing email templates using the react.email library.
Here you can define email templates using React components and export them as a function that returns the email content. Here you can define email templates using React components and export them as a function that returns the email content.

View File

@@ -0,0 +1,90 @@
import {
Body,
Head,
Html,
Preview,
Tailwind,
Text,
render,
} from '@react-email/components';
import { BodyStyle } from '../components/body-style';
import { EmailContent } from '../components/content';
import { EmailHeader } from '../components/header';
import { EmailHeading } from '../components/heading';
import { EmailWrapper } from '../components/wrapper';
import { initializeEmailI18n } from '../lib/i18n';
export async function renderCompanyOfferEmail({
language,
companyData,
}: {
language?: string;
companyData: {
companyName: string;
contactPerson: string;
email: string;
phone?: string;
};
}) {
const namespace = 'company-offer-email';
const { t } = await initializeEmailI18n({
language,
namespace,
});
const to = process.env.CONTACT_EMAIL || '';
const previewText = t(`${namespace}:previewText`, {
companyName: companyData.companyName,
});
const subject = t(`${namespace}:subject`, {
companyName: companyData.companyName,
});
const html = await render(
<Html>
<Head>
<BodyStyle />
</Head>
<Preview>{previewText}</Preview>
<Tailwind>
<Body>
<EmailWrapper>
<EmailHeader>
<EmailHeading>{previewText}</EmailHeading>
</EmailHeader>
<EmailContent>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:companyName`)} {companyData.companyName}
</Text>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:contactPerson`)} {companyData.contactPerson}
</Text>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:email`)} {companyData.email}
</Text>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:phone`)} {companyData.phone || 'N/A'}
</Text>
</EmailContent>
</EmailWrapper>
</Body>
</Tailwind>
</Html>,
);
return {
html,
subject,
to,
};
}

View File

@@ -1,3 +1,4 @@
export * from './emails/invite.email'; export * from './emails/invite.email';
export * from './emails/account-delete.email'; export * from './emails/account-delete.email';
export * from './emails/otp.email'; export * from './emails/otp.email';
export * from './emails/company-offer.email';

View File

@@ -0,0 +1,8 @@
{
"subject": "Uus ettevõtte liitumispäring",
"previewText": "Ettevõte {{companyName}} soovib pakkumist",
"companyName": "Ettevõtte nimi:",
"contactPerson": "Kontaktisik:",
"email": "E-mail:",
"phone": "Telefon:"
}

View File

@@ -5,7 +5,7 @@ export const MailerSchema = z
to: z.string().email(), to: z.string().email(),
// this is not necessarily formatted // this is not necessarily formatted
// as an email so we type it loosely // as an email so we type it loosely
from: z.string().min(1), from: z.string().min(1).optional(),
subject: z.string(), subject: z.string(),
}) })
.and( .and(

View File

@@ -112,6 +112,5 @@
"noTeamsYet": "You don't have any teams yet.", "noTeamsYet": "You don't have any teams yet.",
"createTeam": "Create a team to get started.", "createTeam": "Create a team to get started.",
"createTeamButtonLabel": "Create a Team", "createTeamButtonLabel": "Create a Team",
"createCompanyAccount": "Create Company Account", "createCompanyAccount": "Create Company Account"
"requestCompanyAccount": "Request Company Account"
} }

View File

@@ -113,5 +113,12 @@
"createTeam": "Create a team to get started.", "createTeam": "Create a team to get started.",
"createTeamButtonLabel": "Create a Team", "createTeamButtonLabel": "Create a Team",
"createCompanyAccount": "Create Company Account", "createCompanyAccount": "Create Company Account",
"requestCompanyAccount": "Küsi pakkumist" "requestCompanyAccount": {
"title": "Ettevõtte andmed",
"description": "Pakkumise saamiseks palun sisesta ettevõtte andmed millega MedReport kasutada kavatsed.",
"button": "Küsi pakkumist",
"successTitle": "Päring edukalt saadetud!",
"successDescription": "Saadame teile esimesel võimalusel vastuse",
"successButton": "Tagasi kodulehele"
}
} }

View File

@@ -92,5 +92,11 @@
"description": "This website uses cookies to ensure you get the best experience on our website.", "description": "This website uses cookies to ensure you get the best experience on our website.",
"reject": "Reject", "reject": "Reject",
"accept": "Accept" "accept": "Accept"
},
"formField": {
"companyName": "Ettevõtte nimi",
"contactPerson": "Kontaktisik",
"email": "E-mail",
"phone": "Telefon"
} }
} }

View File

@@ -112,6 +112,5 @@
"noTeamsYet": "You don't have any teams yet.", "noTeamsYet": "You don't have any teams yet.",
"createTeam": "Create a team to get started.", "createTeam": "Create a team to get started.",
"createTeamButtonLabel": "Create a Team", "createTeamButtonLabel": "Create a Team",
"createCompanyAccount": "Create Company Account", "createCompanyAccount": "Create Company Account"
"requestCompanyAccount": "Request Company Account"
} }

View File

@@ -3,7 +3,7 @@
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*":["./*"], "@/*": ["./*"],
"~/*": ["./app/*"], "~/*": ["./app/*"],
"~/config/*": ["./config/*"], "~/config/*": ["./config/*"],
"~/components/*": ["./components/*"], "~/components/*": ["./components/*"],