diff --git a/apps/contact/.gitignore b/apps/contact/.gitignore new file mode 100644 index 00000000..9b1ee42e --- /dev/null +++ b/apps/contact/.gitignore @@ -0,0 +1,175 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/apps/contact/README.md b/apps/contact/README.md new file mode 100644 index 00000000..79f357f9 --- /dev/null +++ b/apps/contact/README.md @@ -0,0 +1,15 @@ +# @crocoder-dev/contact + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.1.45. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/apps/contact/index.ts b/apps/contact/index.ts new file mode 100644 index 00000000..dd2ed56a --- /dev/null +++ b/apps/contact/index.ts @@ -0,0 +1,386 @@ +import { Client, isFullPage } from "@notionhq/client"; +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; +import { nanoid } from "nanoid"; +import z from "zod"; + +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(), +}); + +type RequestBody = z.infer; + +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, +} = import.meta.env.DEV ? import.meta.env : 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: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": 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, i) => ({ + 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: Request & { 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 const POST = async (request: Request) => { + if (request.headers.get("Content-Type") === "application/json") { + try { + const body = (await request.json()) as RequestBody; + const bodyValidationResult = bodyValidationSchema.safeParse(body); + + if (!body || bodyValidationResult.error) { + throw { + statusCode: 400, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": 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(request); + + 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; + } + + return new Response( + JSON.stringify({ + message: "Success", + }), + { + status: 200, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "true", + "X-RateLimit-Limit": limit.toString(), + "X-RateLimit-Remaining": remaining.toString(), + "X-RateLimit-Reset": reset.toString(), + }, + }, + ); + } catch (error) { + const customError = error as Error & { + statusCode?: number; + body?: { + message?: string; + }; + headers?: HeadersInit; + }; + + console.error("Error - api/contacts", customError); + + return new Response( + JSON.stringify({ + message: + customError?.body?.message || "Issue while processing request", + }), + { + status: customError.statusCode || 501, + headers: customError?.headers, + }, + ); + } + } + + return new Response(null, { status: 400 }); +}; diff --git a/apps/contact/package.json b/apps/contact/package.json new file mode 100644 index 00000000..147c9c5e --- /dev/null +++ b/apps/contact/package.json @@ -0,0 +1,18 @@ +{ + "name": "@crocoder-dev/contact", + "version": "0.0.1", + "main": "index.ts", + "dependencies": { + "@notionhq/client": "^2.2.15", + "@upstash/ratelimit": "^2.0.5", + "@upstash/redis": "^1.34.3", + "nanoid": "^5.0.9", + "zod": "^3.24.1" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + } +} \ No newline at end of file diff --git a/apps/contact/tsconfig.json b/apps/contact/tsconfig.json new file mode 100644 index 00000000..238655f2 --- /dev/null +++ b/apps/contact/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/apps/contact/vercel.json b/apps/contact/vercel.json new file mode 100644 index 00000000..548e73c5 --- /dev/null +++ b/apps/contact/vercel.json @@ -0,0 +1,20 @@ +{ + "installCommand": "curl -fsSL https://bun.sh/install | bash && ~/.bun/bin/bun install --frozen-lockfile", + "buildCommand": "~/.bun/bin/bun run build", + "functions": { + "index.ts": { + "memory": 256, + "maxDuration": 5, + "runtime": "vercel/bun" + } + }, + "routes": [ + { + "src": "/", + "methods": [ + "POST" + ], + "dest": "index.ts" + } + ] +} \ No newline at end of file diff --git a/apps/website/astro.config.mjs b/apps/website/astro.config.mjs index dbf17081..12661268 100644 --- a/apps/website/astro.config.mjs +++ b/apps/website/astro.config.mjs @@ -1,7 +1,7 @@ import { defineConfig } from "astro/config"; import tailwind from "@astrojs/tailwind"; import react from "@astrojs/react"; -import vercel from "@astrojs/vercel/static"; +import vercel from "@astrojs/vercel"; export default defineConfig({ output: "static", diff --git a/apps/website/package.json b/apps/website/package.json index a9b3c115..959d6357 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -9,10 +9,11 @@ "@crocoder-dev/tailwind": "*", "astro": "5.1.2", "astro-font": "^0.1.81", - "classnames": "^2.5.1" + "classnames": "^2.5.1", + "react-icons": "^5.4.0" }, "scripts": { "dev": "astro dev --port 4321", "build": "astro build" } -} +} \ No newline at end of file diff --git a/apps/website/public/people-collaborating.png b/apps/website/public/people-collaborating.png new file mode 100644 index 00000000..a3156005 Binary files /dev/null and b/apps/website/public/people-collaborating.png differ diff --git a/apps/website/src/components/ContactForm.astro b/apps/website/src/components/BookACallForm.astro similarity index 60% rename from apps/website/src/components/ContactForm.astro rename to apps/website/src/components/BookACallForm.astro index ff945fa3..39bb49b5 100644 --- a/apps/website/src/components/ContactForm.astro +++ b/apps/website/src/components/BookACallForm.astro @@ -7,24 +7,24 @@ import SectionTitle from "./SectionTitle.astro";
- - Build Your Business
With Us -

- Our software development services helped clients big and small build solutions with a lasting impact. -

-
+ + Build Your Business
With Us +

+ Our software development services helped clients big and small build + solutions with a lasting impact. +

+
-
-
+
+
+ +

+ +

+
+ + +
diff --git a/apps/website/src/components/ContactUsForm.astro b/apps/website/src/components/ContactUsForm.astro new file mode 100644 index 00000000..95bbab26 --- /dev/null +++ b/apps/website/src/components/ContactUsForm.astro @@ -0,0 +1,209 @@ +--- +import Field from "./Field.astro"; +import * as formContent from "../content/contact/form.md"; +import "../styles/loader.css"; +--- + +
+ + +
+ + + 0/1500 + +
+
+
+ +
+ +

+
+ + diff --git a/apps/website/src/components/Field.astro b/apps/website/src/components/Field.astro new file mode 100644 index 00000000..bd5b89af --- /dev/null +++ b/apps/website/src/components/Field.astro @@ -0,0 +1,140 @@ +--- +import type { HTMLAttributes } from "astro/types"; +import classnames from "classnames"; + +type BaseProps = { + labelProps?: HTMLAttributes<"label">; + label?: string; + errorText?: string; + classNames?: string; +}; + +type InputProps = { + isTextArea: false; + inputProps: HTMLAttributes<"input">; +}; + +type TextAreaProps = { + isTextArea: true; + textAreaProps: HTMLAttributes<"textarea">; +}; + +type Props = BaseProps & (InputProps | TextAreaProps); + +const { classNames, labelProps, label, errorText } = Astro.props; +--- + +
+ { + !Astro.props.isTextArea ? ( + + ) : ( +