diff --git a/apps/web/.env.development b/apps/web/.env.development
index 99bb9a23b..03afb0b1b 100644
--- a/apps/web/.env.development
+++ b/apps/web/.env.development
@@ -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=
diff --git a/apps/web/app/(marketing)/contact/_components/contact-form.tsx b/apps/web/app/(marketing)/contact/_components/contact-form.tsx
new file mode 100644
index 000000000..106cf0ffa
--- /dev/null
+++ b/apps/web/app/(marketing)/contact/_components/contact-form.tsx
@@ -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 ;
+ }
+
+ if (state.error) {
+ return ;
+ }
+
+ return (
+
+
+ );
+}
+
+function SuccessAlert() {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function ErrorAlert() {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(marketing)/contact/_lib/contact-email.schema.ts b/apps/web/app/(marketing)/contact/_lib/contact-email.schema.ts
new file mode 100644
index 000000000..4e629db2e
--- /dev/null
+++ b/apps/web/app/(marketing)/contact/_lib/contact-email.schema.ts
@@ -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),
+});
diff --git a/apps/web/app/(marketing)/contact/_lib/server/server-actions.ts b/apps/web/app/(marketing)/contact/_lib/server/server-actions.ts
new file mode 100644
index 000000000..0ce5e5f29
--- /dev/null
+++ b/apps/web/app/(marketing)/contact/_lib/server/server-actions.ts
@@ -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: `
+
+ You have received a new contact form submission.
+
+
+ Name: ${data.name}
+ Email: ${data.email}
+ Message: ${data.message}
+ `,
+ });
+
+ return {};
+ },
+ {
+ schema: ContactEmailSchema,
+ auth: false,
+ },
+);
diff --git a/apps/web/app/(marketing)/contact/page.tsx b/apps/web/app/(marketing)/contact/page.tsx
index b00e2a31a..19b590490 100644
--- a/apps/web/app/(marketing)/contact/page.tsx
+++ b/apps/web/app/(marketing)/contact/page.tsx
@@ -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 (
);
diff --git a/apps/web/app/server-sitemap.xml/route.ts b/apps/web/app/server-sitemap.xml/route.ts
index 6a8b40858..08ea71960 100644
--- a/apps/web/app/server-sitemap.xml/route.ts
+++ b/apps/web/app/server-sitemap.xml/route.ts
@@ -24,8 +24,11 @@ function getSiteUrls() {
const urls = [
'/',
'/faq',
+ '/blog',
+ '/docs',
'/pricing',
'/contact',
+ '/cookie-policy',
'/terms-of-service',
'/privacy-policy',
];
diff --git a/apps/web/public/locales/en/common.json b/apps/web/public/locales/en/common.json
index 62d56b84c..1d2f1f755 100644
--- a/apps/web/public/locales/en/common.json
+++ b/apps/web/public/locales/en/common.json
@@ -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"
}
}
diff --git a/apps/web/public/locales/en/marketing.json b/apps/web/public/locales/en/marketing.json
index 1fb13fed4..323c4a132 100644
--- a/apps/web/public/locales/en/marketing.json
+++ b/apps/web/public/locales/en/marketing.json
@@ -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"
}
diff --git a/packages/next/src/actions/index.ts b/packages/next/src/actions/index.ts
index 2637f2560..1eb8cb041 100644
--- a/packages/next/src/actions/index.ts
+++ b/packages/next/src/actions/index.ts
@@ -19,46 +19,60 @@ import { captureException, zodParseFactory } from '../utils';
*/
export function enhanceAction<
Args,
- Schema extends z.ZodType, z.ZodTypeDef>,
Response,
->(
- fn: (params: z.infer, user: User) => Response | Promise,
- 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,
+ user: Config['auth'] extends false ? undefined : User,
+ ) => Response | Promise,
+ config: Config,
) {
- return async (
- params: z.infer & {
- captchaToken?: string;
- },
- ) => {
- // verify the user is authenticated if required
- const auth = await requireUser(getSupabaseServerActionClient());
+ return async (params: z.infer) => {
+ type UserParam = Config['auth'] extends false ? undefined : User;
- // If the user is not authenticated, redirect to the specified URL.
- if (!auth.data) {
- redirect(auth.redirectTo);
- }
+ const requireAuth = config.auth ?? true;
- // verify the captcha token if required
- if (config.captcha) {
- const token = z.string().min(1).parse(params.captchaToken);
+ let user: UserParam = undefined as UserParam;
- await verifyCaptchaToken(token);
+ if (requireAuth) {
+ // verify the user is authenticated if required
+ const auth = await requireUser(getSupabaseServerActionClient());
+
+ // If the user is not authenticated, redirect to the specified URL.
+ if (!auth.data) {
+ redirect(auth.redirectTo);
+ }
+
+ 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);
}
};
}
diff --git a/packages/ui/package.json b/packages/ui/package.json
index d51be5ca1..a5b4f6e96 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -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": {
"*": {
diff --git a/packages/ui/src/makerkit/cookie-banner.tsx b/packages/ui/src/makerkit/cookie-banner.tsx
new file mode 100644
index 000000000..ec4070628
--- /dev/null
+++ b/packages/ui/src/makerkit/cookie-banner.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function useCookieConsent() {
+ const initialState = getStatusFromLocalStorage();
+ const [status, setStatus] = useState(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';
+}
diff --git a/turbo/generators/templates/package.json.hbs b/turbo/generators/templates/package.json.hbs
index 4f48a683c..6a0c940ae 100644
--- a/turbo/generators/templates/package.json.hbs
+++ b/turbo/generators/templates/package.json.hbs
@@ -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",