diff --git a/apps/contact/app/(helpers)/notion.ts b/apps/contact/app/(helpers)/notion.ts new file mode 100644 index 00000000..51fa3074 --- /dev/null +++ b/apps/contact/app/(helpers)/notion.ts @@ -0,0 +1,166 @@ +import { Client, isFullPage } from "@notionhq/client"; +import { notifyContactCreated } from "./slack"; + +const { NOTION_TOKEN, MENTION_EMAILS, MENTION_IDS } = process.env; + +const notion = new Client({ auth: NOTION_TOKEN }); + +const mentionPerson = ({ id }: { id: string }) => [ + { + mention: { + user: { + id, + }, + }, + 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, + })); + } + } + return []; +}; + +const mentionPeople = () => { + return getMentions().flatMap(mentionPerson); +}; + +const createContactObject = ( + id: string, + email: string, + name: string, + content: string, + databaseID: string, + source: string, +) => ({ + parent: { + database_id: databaseID, + }, + properties: { + id: { + title: [ + { + text: { + content: id, + }, + }, + ], + }, + email: { + email, + }, + name: { + rich_text: [ + { + text: { + content: name, + }, + }, + ], + }, + date: { + date: { + start: new Date().toISOString(), + }, + }, + source: { + rich_text: [ + { + text: { + content: source, + }, + }, + ], + }, + }, + children: [ + { + paragraph: { + rich_text: [ + { + text: { + content, + }, + }, + ], + }, + }, + { + paragraph: { + rich_text: mentionPeople(), + }, + }, + ], +}); + +const createContact = async ( + id: string, + email: string, + name: string, + content: string, + databaseID: string, + source: string, +) => { + const response = await notion.pages.create( + createContactObject(id, email, name, content, databaseID, source), + ); + + if (response.id && isFullPage(response)) { + return { + id: response.id, + url: response.url, + }; + } + throw { + body: { + message: "Failed to create notion page", + }, + }; +}; + +export const processContact = async (event: { + id: string; + email: string; + name: string; + message: string; + databaseID: string; + source: string; +}) => { + const { id, email, name, message, databaseID, source } = event; + + if (!id || !email || !name || !databaseID) { + console.log({ event }); + throw { + body: { + message: "Missing data in process contact event", + }, + }; + } + + const { id: notionPageID, url } = await createContact( + `Message from ${name} (${id})`, + email, + name, + message, + databaseID, + source, + ); + + await notifyContactCreated(name, email, url); + return notionPageID; +}; diff --git a/apps/contact/app/(helpers)/slack.ts b/apps/contact/app/(helpers)/slack.ts new file mode 100644 index 00000000..8114d6d9 --- /dev/null +++ b/apps/contact/app/(helpers)/slack.ts @@ -0,0 +1,73 @@ +const { SLACK_CHANNEL, SLACK_BOT_TOKEN, IS_OFFLINE } = process.env; + +export 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", + }, + }, + ], +}); + +export 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 { + 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 { + body: "Could not send notification message to Slack", + statusCode: result.status, + }; + } + } +}; diff --git a/apps/contact/app/api/contact/route.ts b/apps/contact/app/api/contact/route.ts index 8f2e1534..56b05a37 100644 --- a/apps/contact/app/api/contact/route.ts +++ b/apps/contact/app/api/contact/route.ts @@ -1,5 +1,6 @@ -import { Client, isFullPage } from "@notionhq/client"; +import { processContact } from "@/app/(helpers)/notion"; import { nanoid } from "nanoid"; +import { NextRequest } from "next/server"; import z from "zod"; const bodyValidationSchema = z.object({ @@ -7,254 +8,15 @@ const bodyValidationSchema = z.object({ .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" }), + .email({ message: "Invalid email adress" }) + .min(1, { message: "Required field" }), 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, - IS_OFFLINE, -} = process.env; - -const notion = new Client({ auth: NOTION_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 { NOTION_DATABASE_ID } = process.env; const allowRequest = async (request: Request & { ip?: string }) => { return { success: true, limit: 1, reset: Date.now() + 30000, remaining: 1 }; @@ -271,7 +33,7 @@ export async function OPTIONS() { }); } -export async function POST(request: Request) { +export async function POST(request: NextRequest) { const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Credentials": "true", @@ -330,6 +92,8 @@ export async function POST(request: Request) { email, name, message, + databaseID: NOTION_DATABASE_ID || "", + source: request.nextUrl.searchParams.get("source") || "Unknown", }); return new Response( diff --git a/apps/website/src/assets/contact-hero.svg b/apps/website/src/assets/contact-hero.svg new file mode 100644 index 00000000..8804c54f --- /dev/null +++ b/apps/website/src/assets/contact-hero.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/website/src/components/BaseContactForm.astro b/apps/website/src/components/BaseContactForm.astro new file mode 100644 index 00000000..01bd3198 --- /dev/null +++ b/apps/website/src/components/BaseContactForm.astro @@ -0,0 +1,227 @@ +--- +import classnames from "classnames"; +import * as formContent from "../content/contact/form.md"; +import "../styles/loader.css"; +import Field from "./Field.astro"; + +type Props = { + onDark?: boolean; + classNames?: string; +}; + +const { onDark, classNames } = Astro.props; +--- + +
+ + +
+ + + 0/1500 + +
+ +
+
+ +
+ +

+
+ + diff --git a/apps/website/src/components/BookACallForm.astro b/apps/website/src/components/BookACallForm.astro index bce582a6..315381ae 100644 --- a/apps/website/src/components/BookACallForm.astro +++ b/apps/website/src/components/BookACallForm.astro @@ -1,22 +1,31 @@ --- +import { Image } from "astro:assets"; +import classNames from "classnames"; +import meetingImage from "../assets/meeting-croc.png"; import CalcomEmbedInline from "../components/CalcomEmbedInline.astro"; import { grid_classes } from "./_grid"; import Pill from "./Pill.astro"; import Section from "./Section.astro"; -import { Image } from "astro:assets"; -import meetingImage from "../assets/meeting-croc.png"; export interface Props { calLink?: string; eventType?: string; + className?: string; } -const { calLink = "team/crocoder/hello", eventType = "hello" } = Astro.props; +const { + calLink = "team/crocoder/hello", + eventType = "hello", + className, +} = Astro.props; ---
+

Trusted By:

-
-
+
+
Conductor logo
Our tailored software services bridge the gap between the big-picture strategy diff --git a/apps/website/src/components/Field.astro b/apps/website/src/components/Field.astro index af82df95..63a1c913 100644 --- a/apps/website/src/components/Field.astro +++ b/apps/website/src/components/Field.astro @@ -7,6 +7,7 @@ type BaseProps = { label?: string; errorText?: string; classNames?: string; + onDark?: boolean; }; type InputProps = { @@ -21,7 +22,7 @@ type TextAreaProps = { type Props = BaseProps & (InputProps | TextAreaProps); -const { classNames, labelProps, label, errorText } = Astro.props; +const { classNames, labelProps, label, errorText, onDark } = Astro.props; ---
{label} {errorText || "Invalid field"} @@ -114,18 +121,17 @@ const { classNames, labelProps, label, errorText } = Astro.props;
diff --git a/apps/website/src/components/Values.astro b/apps/website/src/components/Values.astro index 576caeb8..3da5e915 100644 --- a/apps/website/src/components/Values.astro +++ b/apps/website/src/components/Values.astro @@ -37,7 +37,7 @@ const { src } = await getImage({
+
+ +
+
+ + get in touch + +

+ + Let’s Talk About + + + Your Project. + +

+

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

+

+ + Fill the contact form and we’ll get back to you in the next two days + or book a call with us below. + +

+
+ + + + Get a free build review + + +
+
+
diff --git a/apps/website/src/components/icons/RightArrow.astro b/apps/website/src/components/icons/RightArrow.astro new file mode 100644 index 00000000..a7a52a63 --- /dev/null +++ b/apps/website/src/components/icons/RightArrow.astro @@ -0,0 +1,14 @@ + + + diff --git a/apps/website/src/pages/contact.astro b/apps/website/src/pages/contact.astro index cf922a1f..5236c249 100644 --- a/apps/website/src/pages/contact.astro +++ b/apps/website/src/pages/contact.astro @@ -1,15 +1,23 @@ --- -import ContactUs from "../components/ContactUs.astro"; +import BookACallForm from "../components/BookACallForm.astro"; +import Clients from "../components/Clients.astro"; +import Hero from "../components/contact/Hero.astro"; import Footer from "../components/footer.astro"; import Navigation from "../components/navigation.astro"; import Base from "../layouts/base.astro"; import "../styles/main.css"; --- - + -
- -
+ + + + + Everything Kicks Off + with One Call + + +