Add contact form, cookie banner, and update packages
This commit includes the addition of a contact form and a cookie banner to improve user experience and comply with regulations. The contact form involves email submission functionality. Several packages have also been updated and new routes have been added to the sitemap for better SEO. Environment variables have also been adjusted for email and contact form functionality.
This commit is contained in:
@@ -4,10 +4,12 @@
|
||||
# SUPABASE
|
||||
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
|
||||
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
|
||||
|
||||
## THIS IS FOR DEVELOPMENT ONLY - DO NOT USE IN PRODUCTION
|
||||
SUPABASE_DB_WEBHOOK_SECRET=WEBHOOKSECRET
|
||||
|
||||
# EMAILS
|
||||
EMAIL_SENDER=test@makerkit.dev
|
||||
EMAIL_PORT=54325
|
||||
EMAIL_HOST=localhost
|
||||
@@ -15,6 +17,9 @@ EMAIL_TLS=false
|
||||
EMAIL_USER=user
|
||||
EMAIL_PASSWORD=password
|
||||
|
||||
# CONTACT FORM
|
||||
CONTACT_EMAIL=test@makerkit.dev
|
||||
|
||||
# STRIPE
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
||||
|
||||
|
||||
161
apps/web/app/(marketing)/contact/_components/contact-form.tsx
Normal file
161
apps/web/app/(marketing)/contact/_components/contact-form.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Textarea } from '@kit/ui/textarea';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { ContactEmailSchema } from '~/(marketing)/contact/_lib/contact-email.schema';
|
||||
import { sendContactEmail } from '~/(marketing)/contact/_lib/server/server-actions';
|
||||
|
||||
export function ContactForm() {
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const [state, setState] = useState({
|
||||
success: false,
|
||||
error: false,
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(ContactEmailSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
message: '',
|
||||
},
|
||||
});
|
||||
|
||||
if (state.success) {
|
||||
return <SuccessAlert />;
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return <ErrorAlert />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={'flex flex-col space-y-4'}
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await sendContactEmail(data);
|
||||
|
||||
setState({ success: true, error: false });
|
||||
} catch (error) {
|
||||
setState({ error: true, success: false });
|
||||
}
|
||||
});
|
||||
})}
|
||||
>
|
||||
<FormField
|
||||
name={'name'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'marketing:contactName'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input maxLength={200} {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name={'email'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'marketing:contactEmail'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input type={'email'} {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name={'message'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'marketing:contactMessage'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className={'min-h-36'}
|
||||
maxLength={5000}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button disabled={pending} type={'submit'}>
|
||||
<Trans i18nKey={'marketing:sendMessage'} />
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessAlert() {
|
||||
return (
|
||||
<Alert variant={'success'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'marketing:contactSuccess'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'marketing:contactSuccessDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'marketing:contactError'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'marketing:contactErrorDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ContactEmailSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
email: z.string().email(),
|
||||
message: z.string().min(1).max(5000),
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
'use server';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getMailer } from '@kit/mailers';
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
|
||||
import { ContactEmailSchema } from '../contact-email.schema';
|
||||
|
||||
const contactEmail = z
|
||||
.string({
|
||||
description: `The email where you want to receive the contact form submissions.`,
|
||||
required_error:
|
||||
'Contact email is required. Please use the environment variable CONTACT_EMAIL.',
|
||||
})
|
||||
.parse(process.env.CONTACT_EMAIL);
|
||||
|
||||
const emailFrom = z
|
||||
.string({
|
||||
description: `The email sending address.`,
|
||||
required_error:
|
||||
'Sender email is required. Please use the environment variable EMAIL_SENDER.',
|
||||
})
|
||||
.parse(process.env.EMAIL_SENDER);
|
||||
|
||||
export const sendContactEmail = enhanceAction(
|
||||
async (data) => {
|
||||
const mailer = await getMailer();
|
||||
|
||||
await mailer.sendEmail({
|
||||
to: contactEmail,
|
||||
from: emailFrom,
|
||||
subject: 'Contact Form Submission',
|
||||
html: `
|
||||
<p>
|
||||
You have received a new contact form submission.
|
||||
</p>
|
||||
|
||||
<p>Name: ${data.name}</p>
|
||||
<p>Email: ${data.email}</p>
|
||||
<p>Message: ${data.message}</p>
|
||||
`,
|
||||
});
|
||||
|
||||
return {};
|
||||
},
|
||||
{
|
||||
schema: ContactEmailSchema,
|
||||
auth: false,
|
||||
},
|
||||
);
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
|
||||
import { ContactForm } from '~/(marketing)/contact/_components/contact-form';
|
||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
@@ -15,8 +19,33 @@ async function ContactPage() {
|
||||
|
||||
return (
|
||||
<div className={'mt-8'}>
|
||||
<SitePageHeader
|
||||
title={t(`marketing:contact`)}
|
||||
subtitle={t(`marketing:contactDescription`)}
|
||||
/>
|
||||
|
||||
<div className={'container mx-auto'}>
|
||||
<SitePageHeader title={t(`marketing:contact`)} subtitle={``} />
|
||||
<div
|
||||
className={'flex flex-1 flex-col items-center justify-center py-12'}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'flex w-full max-w-lg flex-col space-y-4 rounded-lg border p-8'
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<Heading level={3}>
|
||||
<Trans i18nKey={'marketing:contactHeading'} />
|
||||
</Heading>
|
||||
|
||||
<p className={'text-muted-foreground'}>
|
||||
<Trans i18nKey={'marketing:contactSubheading'} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ContactForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -24,8 +24,11 @@ function getSiteUrls() {
|
||||
const urls = [
|
||||
'/',
|
||||
'/faq',
|
||||
'/blog',
|
||||
'/docs',
|
||||
'/pricing',
|
||||
'/contact',
|
||||
'/cookie-policy',
|
||||
'/terms-of-service',
|
||||
'/privacy-policy',
|
||||
];
|
||||
|
||||
@@ -55,5 +55,11 @@
|
||||
"member": {
|
||||
"label": "Member"
|
||||
}
|
||||
},
|
||||
"cookieBanner": {
|
||||
"title": "Hey, we use cookies \uD83C\uDF6A",
|
||||
"description": "This website uses cookies to ensure you get the best experience on our website.",
|
||||
"reject": "Reject",
|
||||
"accept": "Accept"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,5 +22,16 @@
|
||||
"cookiePolicy": "Cookie Policy",
|
||||
"cookiePolicyDescription": "Our cookie policy and how we use them",
|
||||
"privacyPolicy": "Privacy Policy",
|
||||
"privacyPolicyDescription": "Our privacy policy and how we use your data"
|
||||
"privacyPolicyDescription": "Our privacy policy and how we use your data",
|
||||
"contactDescription": "Contact us for any questions or feedback",
|
||||
"contactHeading": "Send us a message",
|
||||
"contactSubheading": "We will get back to you as soon as possible",
|
||||
"contactName": "Your Name",
|
||||
"contactEmail": "Your Email",
|
||||
"contactMessage": "Your Message",
|
||||
"sendMessage": "Send Message",
|
||||
"contactSuccess": "Your message has been sent successfully",
|
||||
"contactError": "An error occurred while sending your message",
|
||||
"contactSuccessDescription": "We have received your message and will get back to you as soon as possible",
|
||||
"contactErrorDescription": "An error occurred while sending your message. Please try again later"
|
||||
}
|
||||
|
||||
@@ -19,21 +19,31 @@ import { captureException, zodParseFactory } from '../utils';
|
||||
*/
|
||||
export function enhanceAction<
|
||||
Args,
|
||||
Schema extends z.ZodType<Omit<Args, 'captchaToken'>, z.ZodTypeDef>,
|
||||
Response,
|
||||
>(
|
||||
fn: (params: z.infer<Schema>, user: User) => Response | Promise<Response>,
|
||||
config: {
|
||||
Config extends {
|
||||
auth?: boolean;
|
||||
captcha?: boolean;
|
||||
captureException?: boolean;
|
||||
schema: Schema;
|
||||
schema: z.ZodType<
|
||||
Config['captcha'] extends true ? Args & { captchaToken: string } : Args,
|
||||
z.ZodTypeDef
|
||||
>;
|
||||
},
|
||||
>(
|
||||
fn: (
|
||||
params: z.infer<Config['schema']>,
|
||||
user: Config['auth'] extends false ? undefined : User,
|
||||
) => Response | Promise<Response>,
|
||||
config: Config,
|
||||
) {
|
||||
return async (
|
||||
params: z.infer<Schema> & {
|
||||
captchaToken?: string;
|
||||
},
|
||||
) => {
|
||||
return async (params: z.infer<Config['schema']>) => {
|
||||
type UserParam = Config['auth'] extends false ? undefined : User;
|
||||
|
||||
const requireAuth = config.auth ?? true;
|
||||
|
||||
let user: UserParam = undefined as UserParam;
|
||||
|
||||
if (requireAuth) {
|
||||
// verify the user is authenticated if required
|
||||
const auth = await requireUser(getSupabaseServerActionClient());
|
||||
|
||||
@@ -42,23 +52,27 @@ export function enhanceAction<
|
||||
redirect(auth.redirectTo);
|
||||
}
|
||||
|
||||
// verify the captcha token if required
|
||||
if (config.captcha) {
|
||||
const token = z.string().min(1).parse(params.captchaToken);
|
||||
|
||||
await verifyCaptchaToken(token);
|
||||
user = auth.data as UserParam;
|
||||
}
|
||||
|
||||
// validate the schema
|
||||
const parsed = zodParseFactory(config.schema);
|
||||
const data = parsed(params);
|
||||
|
||||
// verify the captcha token if required
|
||||
if (config.captcha) {
|
||||
const token = (data as Args & { captchaToken: string }).captchaToken;
|
||||
|
||||
// Verify the CAPTCHA token. It will throw an error if the token is invalid.
|
||||
await verifyCaptchaToken(token);
|
||||
}
|
||||
|
||||
// capture exceptions if required
|
||||
const shouldCaptureException = config.captureException ?? true;
|
||||
|
||||
if (shouldCaptureException) {
|
||||
try {
|
||||
return await fn(data, auth.data);
|
||||
return await fn(data, user);
|
||||
} catch (error) {
|
||||
await captureException(error);
|
||||
|
||||
@@ -66,7 +80,7 @@ export function enhanceAction<
|
||||
}
|
||||
} else {
|
||||
// pass the data to the action
|
||||
return fn(data, auth.data);
|
||||
return fn(data, user);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,7 +113,8 @@
|
||||
"./mode-toggle": "./src/makerkit/mode-toggle.tsx",
|
||||
"./enhanced-data-table": "./src/makerkit/data-table.tsx",
|
||||
"./language-selector": "./src/makerkit/language-selector.tsx",
|
||||
"./stepper": "./src/makerkit/stepper.tsx"
|
||||
"./stepper": "./src/makerkit/stepper.tsx",
|
||||
"./cookie-banner": "./src/makerkit/cookie-banner.tsx"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
|
||||
120
packages/ui/src/makerkit/cookie-banner.tsx
Normal file
120
packages/ui/src/makerkit/cookie-banner.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
|
||||
import { Button } from '../shadcn/button';
|
||||
import { Heading } from '../shadcn/heading';
|
||||
import { Trans } from './trans';
|
||||
|
||||
// configure this as you wish
|
||||
const COOKIE_CONSENT_STATUS = 'cookie_consent_status';
|
||||
|
||||
enum ConsentStatus {
|
||||
Accepted = 'accepted',
|
||||
Rejected = 'rejected',
|
||||
Unknown = 'unknown',
|
||||
}
|
||||
|
||||
export function CookieBanner() {
|
||||
const { status, accept, reject } = useCookieConsent();
|
||||
|
||||
if (!isBrowser()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status !== ConsentStatus.Unknown) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root open modal={false}>
|
||||
<DialogPrimitive.Content
|
||||
className={`dark:shadow-primary-500/40 fixed bottom-0
|
||||
w-full max-w-lg border bg-background p-6 shadow-2xl
|
||||
delay-1000 duration-1000 animate-in fade-in zoom-in-95
|
||||
slide-in-from-bottom-16 fill-mode-both lg:bottom-[2rem] lg:left-[2rem] lg:h-48 lg:rounded-lg`}
|
||||
>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div>
|
||||
<Heading level={3}>
|
||||
<Trans i18nKey={'cookieBanner.title'} />
|
||||
</Heading>
|
||||
</div>
|
||||
|
||||
<div className={'text-gray-500 dark:text-gray-400'}>
|
||||
<Trans i18nKey={'cookieBanner.description'} />
|
||||
</div>
|
||||
|
||||
<div className={'flex justify-end space-x-2.5'}>
|
||||
<Button variant={'ghost'} onClick={reject}>
|
||||
<Trans i18nKey={'cookieBanner.reject'} />
|
||||
</Button>
|
||||
|
||||
<Button autoFocus onClick={accept}>
|
||||
<Trans i18nKey={'cookieBanner.accept'} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCookieConsent() {
|
||||
const initialState = getStatusFromLocalStorage();
|
||||
const [status, setStatus] = useState<ConsentStatus>(initialState);
|
||||
|
||||
const accept = useCallback(() => {
|
||||
const status = ConsentStatus.Accepted;
|
||||
|
||||
setStatus(status);
|
||||
storeStatusInLocalStorage(status);
|
||||
}, []);
|
||||
|
||||
const reject = useCallback(() => {
|
||||
const status = ConsentStatus.Rejected;
|
||||
|
||||
setStatus(status);
|
||||
storeStatusInLocalStorage(status);
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
const status = ConsentStatus.Unknown;
|
||||
|
||||
setStatus(status);
|
||||
storeStatusInLocalStorage(status);
|
||||
}, []);
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
clear,
|
||||
status,
|
||||
accept,
|
||||
reject,
|
||||
};
|
||||
}, [clear, status, accept, reject]);
|
||||
}
|
||||
|
||||
function storeStatusInLocalStorage(status: ConsentStatus) {
|
||||
if (!isBrowser()) {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(COOKIE_CONSENT_STATUS, status);
|
||||
}
|
||||
|
||||
function getStatusFromLocalStorage() {
|
||||
if (!isBrowser()) {
|
||||
return ConsentStatus.Unknown;
|
||||
}
|
||||
|
||||
const status = localStorage.getItem(COOKIE_CONSENT_STATUS) as ConsentStatus;
|
||||
|
||||
return status ?? ConsentStatus.Unknown;
|
||||
}
|
||||
|
||||
function isBrowser() {
|
||||
return typeof window !== 'undefined';
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"exports": {
|
||||
".": "./server.ts"
|
||||
".": "./index.ts"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
@@ -11,7 +11,7 @@
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
|
||||
Reference in New Issue
Block a user