diff --git a/apps/contact/.gitignore b/apps/contact/.gitignore index 5ef6a520..a093d7d0 100644 --- a/apps/contact/.gitignore +++ b/apps/contact/.gitignore @@ -1,41 +1,38 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. -# dependencies +# Dependencies /node_modules /.pnp -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/versions - -# testing -/coverage +.pnp.js -# next.js -/.next/ -/out/ +# Testing +/coverage -# production -/build +# Production +build +dist -# misc +# Misc .DS_Store *.pem -# debug +# Debug npm-debug.log* yarn-debug.log* yarn-error.log* -.pnpm-debug.log* -# env files (can opt-in for committing if needed) -.env* +# Local ENV files +.env.local +.env.development.local +.env.test.local +.env.production.local -# vercel +# Vercel .vercel +# Turborepo +.turbo + # typescript *.tsbuildinfo -next-env.d.ts +.env*.local diff --git a/apps/contact/README.md b/apps/contact/README.md deleted file mode 100644 index e215bc4c..00000000 --- a/apps/contact/README.md +++ /dev/null @@ -1,36 +0,0 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). - -## Getting Started - -First, run the development server: - -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/apps/contact/api/index.ts b/apps/contact/api/index.ts new file mode 100644 index 00000000..b9542721 --- /dev/null +++ b/apps/contact/api/index.ts @@ -0,0 +1,394 @@ +import { Client, isFullPage } from "@notionhq/client"; +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; +import { nanoid } from "nanoid"; +import z from "zod"; +import type { VercelRequest, VercelResponse } from "@vercel/node"; + +const bodyValidationSchema = z.object({ + name: z + .string() + .min(3, { message: "Name must be at least 3 characters long" }), + email: z + .string() + .min(1, { message: "Required field" }) + .email({ message: "Invalid email adress" }), + message: z.string().min(1, { message: "Required field" }), + hasConsent: z.boolean().optional(), +}); + +const { + NOTION_TOKEN, + SLACK_CHANNEL, + SLACK_BOT_TOKEN, + MENTION_EMAILS, + MENTION_IDS, + NOTION_DATABASE_ID, + UPSTASH_REDIS_REST_URL, + UPSTASH_REDIS_REST_TOKEN, + IS_OFFLINE, +} = process.env; + +const notion = new Client({ auth: NOTION_TOKEN }); + +const redis = new Redis({ + url: UPSTASH_REDIS_REST_URL, + token: UPSTASH_REDIS_REST_TOKEN, +}); + +const createPayload = (name: string, email: string, url: string) => ({ + channel: SLACK_CHANNEL, + blocks: [ + { + type: "header", + text: { + type: "plain_text", + text: "We have 1 new message(s).", + emoji: true, + }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `We got a new message from _${name}_ (_${email}_).`, + }, + }, + { + type: "divider", + }, + { + type: "section", + text: { + type: "mrkdwn", + text: " ", + }, + accessory: { + type: "button", + text: { + type: "plain_text", + text: "Show me the message", + emoji: true, + }, + value: "new_message_click", + url, + action_id: "button-action", + }, + }, + ], +}); + +const notifyContactCreated = async ( + name: string, + email: string, + url: string, +) => { + const payload = createPayload(name, email, url); + const payloadStringify = JSON.stringify(payload); + + if (IS_OFFLINE) { + console.log(payload); + } else { + try { + const result = await fetch("https://slack.com/api/chat.postMessage", { + method: "POST", + body: payloadStringify, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Content-Length": payloadStringify.length.toString(), + Authorization: `Bearer ${SLACK_BOT_TOKEN}`, + Accept: "application/json", + }, + }); + if (result.status !== 200) { + throw { + statusCode: result.status, + headers: [ + { name: "Access-Control-Allow-Origin", value: "*" }, + { name: "Access-Control-Allow-Credentials", value: "true" }, + ], + }; + } + } catch (error) { + throw error; + } + } +}; + +const mentionPerson = ({ id, email }: { id: string; email: string }) => [ + { + mention: { + user: { + id, + person: { + email, + }, + }, + }, + plain_text: "", + href: null, + }, + { + text: { + content: " ", + }, + }, +]; + +const getMentions = () => { + if (MENTION_EMAILS && MENTION_IDS) { + const emails = MENTION_EMAILS.split(","); + const ids = MENTION_IDS.split(","); + + if (emails.length && ids.length) { + return ids.map((id: any, i: number) => ({ + id, + email: emails[i], + })); + } + } + return []; +}; + +const mentionPeople = () => { + return getMentions().flatMap(mentionPerson); +}; + +const createContactObject = ( + id: string, + email: string, + name: string, + content: string, +) => ({ + parent: { + database_id: NOTION_DATABASE_ID || "", + }, + properties: { + id: { + title: [ + { + text: { + content: id, + }, + }, + ], + }, + email: { + email, + }, + name: { + rich_text: [ + { + text: { + content: name, + }, + }, + ], + }, + date: { + date: { + start: new Date().toISOString(), + }, + }, + }, + children: [ + { + paragraph: { + rich_text: [ + { + text: { + content, + }, + }, + ], + }, + }, + { + paragraph: { + rich_text: mentionPeople(), + }, + }, + ], +}); + +const createContact = async ( + id: string, + email: string, + name: string, + content: string, +) => { + try { + const response = await notion.pages.create( + createContactObject(id, email, name, content), + ); + + if (response.id && isFullPage(response)) { + return response.url; + } + throw { + body: { + message: "Failed to create notion page", + }, + }; + } catch (error) { + throw error; + } +}; + +const processContact = async (event: { + id: string; + email: string; + name: string; + message: string; +}) => { + try { + const { id, email, name, message } = event; + + if (!id || !email || !name || !message) { + throw { + body: { + message: "Missing id, email, name or message", + }, + }; + } + + const url = await createContact( + `Message from ${name} (${id})`, + email, + name, + message, + ); + await notifyContactCreated(name, email, url); + } catch (error) { + throw error; + } +}; + +const allowRequest = async (request: VercelRequest & { ip?: string }) => { + try { + const ip = request.ip ?? "127.0.0.1"; + + const ratelimit = new Ratelimit({ + limiter: Ratelimit.fixedWindow(1, "30 s"), + /** Use fromEnv() to automatically load connection secrets from your environment + * variables. For instance when using the Vercel integration. + * + * This tries to load `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` from + * your environment using `import.meta.env`. + */ + redis, + }); + + const response = await ratelimit.limit(ip); + return response; + } catch (error) { + throw { + body: { + message: error, + }, + }; + } +}; + +export default async function handler(req: VercelRequest, res: VercelResponse) { + const allowedOrigins = [ + "https://company-website-git-feat-web-27-add-contact-us-project-crocoder.vercel.app", + "https://company-website-crocoder.vercel.app", + "https://company-website-git-main-crocoder.vercel.app", + ]; + const origin = req.headers.origin; + + if (origin && allowedOrigins.includes(origin)) { + res.setHeader("Access-Control-Allow-Origin", origin); + } else { + res.setHeader("Access-Control-Allow-Origin", ""); + } + + res.setHeader("Access-Control-Allow-Methods", "POST"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + res.setHeader("Access-Control-Allow-Credentials", "true"); + + if (req.method === "OPTIONS") { + res.status(204).end(); + return; + } + + try { + const body = req.body; + const bodyValidationResult = bodyValidationSchema.safeParse(body); + + if (!body || bodyValidationResult.error) { + throw { + statusCode: 400, + headers: [ + { name: "Access-Control-Allow-Origin", value: "*" }, + { name: "Access-Control-Allow-Credentials", value: "true" }, + ], + body: { + message: bodyValidationResult.error?.message || "No body was found", + }, + }; + } + + const { name, email, message, hasConsent } = body; + + if (!hasConsent) { + throw { + statusCode: 403, + body: { + message: "No consent by user", + }, + }; + } + + const { success, limit, reset, remaining } = await allowRequest(req); + + if (!success) { + throw { + statusCode: 429, + body: { + message: "Too many requests. Please try again in a minute", + }, + }; + } + + try { + await processContact({ + id: nanoid(), + email, + name, + message, + }); + } catch (error) { + throw error; + } + + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Credentials", "true"); + res.setHeader("X-RateLimit-Limit", limit.toString()); + res.setHeader("X-RateLimit-Remaining", remaining.toString()); + res.setHeader("X-RateLimit-Reset", reset.toString()); + + return res.status(200).json({ message: "Success" }); + } catch (error) { + const customError = error as Error & { + statusCode?: number; + body?: { + message?: string; + }; + headers?: Array<{ name: string; value: string }>; + }; + + console.error("Error - api/contacts", customError); + + if (customError && customError.headers) { + customError.headers.forEach((header) => { + res.setHeader(header.name, header.value); + }); + } + + return res.status(customError.statusCode || 501).json({ + message: customError?.body?.message || "Issue while processing request", + }); + } +} diff --git a/apps/contact/package.json b/apps/contact/package.json index 02123027..3829cb75 100644 --- a/apps/contact/package.json +++ b/apps/contact/package.json @@ -2,6 +2,8 @@ "name": "contact", "version": "0.1.0", "private": true, + "main": "api/index.ts", + "type": "module", "scripts": { "dev": "next dev --turbopack", "build": "next build", @@ -24,4 +26,4 @@ "@types/react": "^19", "@types/react-dom": "^19" } -} +} \ No newline at end of file diff --git a/apps/contact/tsconfig.json b/apps/contact/tsconfig.json index d8b93235..78979fd3 100644 --- a/apps/contact/tsconfig.json +++ b/apps/contact/tsconfig.json @@ -1,27 +1,10 @@ { "compilerOptions": { - "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "plugins": [ - { - "name": "next" - } - ], - "paths": { - "@/*": ["./*"] - } - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] -} + "module": "ESNext", // or "Node16", "ES2020" + "target": "ESNext", // Set target to ESNext for modern JavaScript features + "moduleResolution": "Node", // Ensures module resolution works as expected + "esModuleInterop": true, // Allows for better interop with CommonJS modules + "strict": true, // Optional but recommended for better type safety + "skipLibCheck": true // Optionally skip library checks for faster builds + } +} \ No newline at end of file diff --git a/apps/contact/vercel.json b/apps/contact/vercel.json new file mode 100644 index 00000000..b5bb67b7 --- /dev/null +++ b/apps/contact/vercel.json @@ -0,0 +1,18 @@ +{ + "version": 2, + "functions": { + "api/index.ts": { + "memory": 256, + "maxDuration": 5 + } + }, + "routes": [ + { + "src": "/api/index.ts", + "methods": [ + "POST" + ], + "dest": "/" + } + ] +} diff --git a/apps/website/astro.config.mjs b/apps/website/astro.config.mjs index 38f14b53..508da496 100644 --- a/apps/website/astro.config.mjs +++ b/apps/website/astro.config.mjs @@ -1,34 +1,46 @@ -import { defineConfig } from 'astro/config'; -import tailwind from '@astrojs/tailwind'; -import react from '@astrojs/react'; -import vercel from '@astrojs/vercel/static'; -import createRemarkPlugin from '@crocoder-dev/remark-plugin'; +import { defineConfig } from "astro/config"; +import tailwind from "@astrojs/tailwind"; +import react from "@astrojs/react"; +import vercel from "@astrojs/vercel/static"; +import createRemarkPlugin from "@crocoder-dev/remark-plugin"; const classes = { - titleClass: 'font-bold text-[1.25rem] mt-[2.5rem]', - summaryClass: 'cursor-pointer font-bold text-[1.25rem]', - detailsClass: 'mt-[2.5rem]', - iframeClass: 'border-none w-full h-[380px] overflow-y-hidden' -} + titleClass: "font-bold text-[1.25rem] mt-[2.5rem]", + summaryClass: "cursor-pointer font-bold text-[1.25rem]", + detailsClass: "mt-[2.5rem]", + iframeClass: "border-none w-full h-[380px] overflow-y-hidden", +}; const remarkPlugin = createRemarkPlugin(classes); export default defineConfig({ - output: 'static', + output: "static", adapter: vercel({ imageService: true, imagesConfig: { sizes: [640, 936], - domains: ['*'], + domains: ["*"], }, }), redirects: { - '/feed': '/rss.xml', - '/sitemap': '/sitemap.xml' + "/feed": "/rss.xml", + "/sitemap": "/sitemap.xml", }, integrations: [tailwind(), react()], markdown: { remarkPlugins: [remarkPlugin], }, + vite: { + server: { + proxy: { + "/api": + "https://web-contact.vercel.app", // Proxy API requests to your backend + }, + }, + define: { + "process.env.VITE_CONTACT_URL": JSON.stringify( + process.env.VITE_CONTACT_URL, + ), + }, + }, }); - diff --git a/apps/website/src/components/ContactUsForm.astro b/apps/website/src/components/ContactUsForm.astro index 95bbab26..7791ee9f 100644 --- a/apps/website/src/components/ContactUsForm.astro +++ b/apps/website/src/components/ContactUsForm.astro @@ -176,19 +176,23 @@ import "../styles/loader.css"; } showLoader(); - const response = await fetch("/contact.json", { - method: "POST", - mode: "cors", - headers: { - "Content-Type": "application/json", + + const response = await fetch( + `https://web-contact.vercel.app`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify({ + name, + email, + message, + hasConsent: consent ? true : false, + }), }, - body: JSON.stringify({ - name, - email, - message, - hasConsent: consent ? true : false, - }), - }); + ); if (response.status !== 200 && notificationElem) { notificationElem.classList.add("text-red-500"); diff --git a/apps/website/src/env.d.ts b/apps/website/src/env.d.ts index ae5f5871..fe02d0f9 100644 --- a/apps/website/src/env.d.ts +++ b/apps/website/src/env.d.ts @@ -2,15 +2,7 @@ /// interface ImportMetaEnv { - readonly NOTION_TOKEN: string; - readonly SLACK_CHANNEL: string; - readonly IS_OFFLINE: string; - readonly SLACK_BOT_TOKEN: string; - readonly MENTION_EMAILS: string; - readonly MENTION_IDS: string; - readonly NOTION_DATABASE_ID: string; - readonly UPSTASH_REDIS_REST_URL: string; - readonly UPSTASH_REDIS_REST_TOKEN: string; + readonly VITE_CONTACT_URL: string; } interface ImportMeta { diff --git a/apps/website/vercel.json b/apps/website/vercel.json new file mode 100644 index 00000000..fba341ff --- /dev/null +++ b/apps/website/vercel.json @@ -0,0 +1,32 @@ +{ + "rewrites": [ + { + "source": "/images/:path*", + "destination": "https://crocoder-company-blog.vercel.app/images/:path*" + }, + { + "source": "/_vercel/:path*", + "destination": "https://crocoder-company-blog.vercel.app/_vercel/:path*" + }, + { + "source": "/_astro/:path*", + "destination": "https://crocoder-company-blog.vercel.app/_astro/:path*" + }, + { + "source": "/blog/:path*", + "destination": "https://crocoder-company-blog.vercel.app/:path*" + }, + { + "source": "/rss.xml", + "destination": "https://crocoder-company-blog.vercel.app/rss.xml" + }, + { + "source": "/feed", + "destination": "https://crocoder-company-blog.vercel.app/rss.xml" + }, + { + "source": "/api", + "destination": "https://web-contact.vercel.app" + } + ] +} \ No newline at end of file diff --git a/turbo.json b/turbo.json index 85bfc80c..5851a647 100644 --- a/turbo.json +++ b/turbo.json @@ -1,15 +1,24 @@ { "tasks": { "build": { - "dependsOn": ["^build"], - "inputs": ["$TURBO_DEFAULT$", ".env*"], + "dependsOn": [ + "^build" + ], + "inputs": [ + "$TURBO_DEFAULT$", + ".env*" + ], "outputs": [] }, "apps/website#build": { - "outputs": ["apps/website/dist/**"] + "outputs": [ + "apps/website/dist/**" + ] }, "lint": { - "dependsOn": ["^lint"] + "dependsOn": [ + "^lint" + ] }, "dev": { "cache": false, @@ -25,6 +34,7 @@ "NOTION_TOKEN", "SLACK_BOT_TOKEN", "NOTION_DATABASE_ID", - "SITE_URL" + "SITE_URL", + "VITE_CONTACT_URL" ] -} +} \ No newline at end of file