diff --git a/apps/contact/app/api/contact/route.tsx b/apps/contact/app/api/contact/route.tsx index ff104f18..024cb6c2 100644 --- a/apps/contact/app/api/contact/route.tsx +++ b/apps/contact/app/api/contact/route.tsx @@ -6,9 +6,7 @@ import { NextRequest } from "next/server"; import z from "zod"; const bodyValidationSchema = z.object({ - name: z - .string() - .min(2, { message: "Name must be at least 2 characters long" }), + name: z.string(), email: z .email({ message: "Invalid email adress" }) .min(1, { message: "Required field" }), diff --git a/apps/contact/app/api/get-plan/route.ts b/apps/contact/app/api/get-plan/route.ts new file mode 100644 index 00000000..5573f731 --- /dev/null +++ b/apps/contact/app/api/get-plan/route.ts @@ -0,0 +1,142 @@ +import { processContact } from "@/helpers/notion"; +import { nanoid } from "nanoid"; +import { NextRequest } from "next/server"; +import z from "zod"; + +const { NOTION_GET_PLAN_DATABASE_ID } = process.env; + +const bodyValidationSchema = z.object({ + name: z.string(), + email: z + .email({ message: "Invalid email adress" }) + .min(1, { message: "Required field" }), + companyName: z.string().optional(), + mainChallenge: z.string().optional(), + expectedStartDate: z.string().optional(), + hasConsent: z.boolean().optional(), +}); + +type RequestBody = z.infer; + +const allowRequest = async (request: Request & { ip?: string }) => { + return { success: true, limit: 1, reset: Date.now() + 30000, remaining: 1 }; +}; + +export async function OPTIONS() { + return new Response(null, { + status: 200, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "OPTIONS, POST", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + }, + }); +} + +export async function POST(request: NextRequest) { + const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + }; + + 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) { + return new Response( + JSON.stringify({ + message: bodyValidationResult.error?.message || "No body was found", + }), + { + status: 400, + headers: { + ...corsHeaders, + }, + }, + ); + } + + const { + name, + email, + companyName, + mainChallenge, + expectedStartDate, + hasConsent, + } = body; + + const message = ` + Company name: ${companyName || ""} \n + Main challenge: ${mainChallenge || ""} \n + Expected start date: ${expectedStartDate || ""} \n`; + + if (!hasConsent) { + return new Response(JSON.stringify({ message: "No consent by user" }), { + status: 403, + headers: { + ...corsHeaders, + }, + }); + } + + const { success, limit, reset, remaining } = await allowRequest(request); + + if (!success) { + return new Response( + JSON.stringify({ + message: "Too many requests. Please try again in a minute", + }), + { + status: 429, + headers: { + ...corsHeaders, + }, + }, + ); + } + + await processContact({ + id: nanoid(), + email, + name, + message: message || "", + databaseID: NOTION_GET_PLAN_DATABASE_ID || "", + source: request.nextUrl.searchParams.get("source") || "Unknown", + }); + + return new Response( + JSON.stringify({ + message: "Success", + }), + { + status: 200, + headers: { + ...corsHeaders, + "X-RateLimit-Limit": limit.toString(), + "X-RateLimit-Remaining": remaining.toString(), + "X-RateLimit-Reset": reset.toString(), + }, + }, + ); + } catch (error) { + console.error("Error - api/get-plan", error); + + const statusCode = (error as any).statusCode || 501; + const message = + (error as any)?.body?.message || "Issue while processing request"; + + return new Response(JSON.stringify({ message }), { + status: statusCode, + headers: { + ...corsHeaders, + }, + }); + } + } + + return new Response(null, { status: 400, headers: corsHeaders }); +} diff --git a/apps/website/astro.config.mjs b/apps/website/astro.config.mjs index ddd9212e..f971097b 100644 --- a/apps/website/astro.config.mjs +++ b/apps/website/astro.config.mjs @@ -19,7 +19,10 @@ export default defineConfig({ adapter: vercel({ imageService: true, imagesConfig: { - sizes: [320, 480, 578, 640, 720, 800, 940, 1200, 1412, 1536, 1800, 1920], + sizes: [ + 320, 480, 578, 640, 720, 800, 940, 960, 1200, 1280, 1412, 1440, 1536, + 1600, 1800, 1920, + ], formats: ["image/avif", "image/webp"], }, }), diff --git a/apps/website/public/target_large.svg b/apps/website/public/target_large.svg new file mode 100644 index 00000000..2716d765 --- /dev/null +++ b/apps/website/public/target_large.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/website/src/assets/code_monitor_dark.svg b/apps/website/src/assets/code_monitor_dark.svg new file mode 100644 index 00000000..0fb3b884 --- /dev/null +++ b/apps/website/src/assets/code_monitor_dark.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/website/src/assets/croco_in_cloud.png b/apps/website/src/assets/croco_in_cloud.png new file mode 100644 index 00000000..85eba119 Binary files /dev/null and b/apps/website/src/assets/croco_in_cloud.png differ diff --git a/apps/website/src/assets/crocodile-board_v3.png b/apps/website/src/assets/crocodile-board_v3.png new file mode 100644 index 00000000..d2e447cd Binary files /dev/null and b/apps/website/src/assets/crocodile-board_v3.png differ diff --git a/apps/website/src/assets/performance_increase.svg b/apps/website/src/assets/performance_increase.svg new file mode 100644 index 00000000..e48c21c0 --- /dev/null +++ b/apps/website/src/assets/performance_increase.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/website/src/assets/pic_phase_3.png b/apps/website/src/assets/pic_phase_3.png index 3983f215..7b5953e0 100644 Binary files a/apps/website/src/assets/pic_phase_3.png and b/apps/website/src/assets/pic_phase_3.png differ diff --git a/apps/website/src/assets/staff-hero.svg b/apps/website/src/assets/staff-hero.svg new file mode 100644 index 00000000..d30b1100 --- /dev/null +++ b/apps/website/src/assets/staff-hero.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/website/src/assets/staff/gloria.jpg b/apps/website/src/assets/staff/gloria.jpg new file mode 100644 index 00000000..7f927548 Binary files /dev/null and b/apps/website/src/assets/staff/gloria.jpg differ diff --git a/apps/website/src/assets/staff/marija.jpg b/apps/website/src/assets/staff/marija.jpg new file mode 100644 index 00000000..d726d1f8 Binary files /dev/null and b/apps/website/src/assets/staff/marija.jpg differ diff --git a/apps/website/src/assets/staff/stefan.jpg b/apps/website/src/assets/staff/stefan.jpg new file mode 100644 index 00000000..6df840eb Binary files /dev/null and b/apps/website/src/assets/staff/stefan.jpg differ diff --git a/apps/website/src/assets/staff_collaboration.png b/apps/website/src/assets/staff_collaboration.png new file mode 100644 index 00000000..858d83e1 Binary files /dev/null and b/apps/website/src/assets/staff_collaboration.png differ diff --git a/apps/website/src/components/BaseContactForm.astro b/apps/website/src/components/BaseContactForm.astro index 641cc87f..14a5eee3 100644 --- a/apps/website/src/components/BaseContactForm.astro +++ b/apps/website/src/components/BaseContactForm.astro @@ -1,8 +1,8 @@ --- import classnames from "classnames"; -import * as formContent from "../content/contact/form.md"; import "../styles/loader.css"; import Field from "./Field.astro"; +import * as formContent from "../content/contact/form.md"; type Props = { formId: string; @@ -18,7 +18,7 @@ const { onDark, classNames, formId, formClassNames } = Astro.props;
@@ -113,7 +114,6 @@ const { onDark, classNames, formId, formClassNames } = Astro.props; id="form-submit-button" class="rounded-md min-h-14 - group-invalid/contact-us:pointer-events-none group-invalid/contact-us:focus-visible:outline-transparent bg-crocoder-yellow hover:bg-crocoder-yellow/90 @@ -125,7 +125,7 @@ const { onDark, classNames, formId, formClassNames } = Astro.props; text-center text-secondary text-base - font-medium mt-6" + font-medium mt-6 cursor-pointer" > { @@ -151,10 +151,11 @@ const { onDark, classNames, formId, formClassNames } = Astro.props; if (!formId) return; - const messageField = document.getElementById("form-message"); + const messageInput = + document.querySelector("#form-message"); const charCounter = document.getElementById("char-counter"); - const form = document.getElementById(formId); + const form = document.getElementById(formId) as HTMLFormElement | null; const submitBtn = form?.querySelector("#form-submit-button"); const submitBtnContent = @@ -162,11 +163,26 @@ const { onDark, classNames, formId, formClassNames } = Astro.props; const submitBtnLoader = submitBtn?.querySelectorAll("#submit-loader")[0]; const notificationElem = form?.querySelector( - "#form-notification" + "#form-notification", ) as HTMLElement; const currentUrl = new URL(window.location.href); + const handleValidation = ( + input: HTMLInputElement | HTMLTextAreaElement | null, + errorMessage: string, + form: HTMLFormElement | null, + ) => { + if (!input) return false; + + const errorSpan = form?.querySelector(`#error-text-value-${input.id}`); + const placeholderSpan = form?.querySelector(`#label-value-${input.id}`); + + placeholderSpan?.classList.add("hidden"); + errorSpan?.classList.add("!inline", "text-red-500"); + if (errorSpan) errorSpan.textContent = errorMessage; + }; + const showLoader = () => { if (submitBtnContent && submitBtnLoader) { submitBtnContent.classList.add("hidden"); @@ -188,11 +204,36 @@ const { onDark, classNames, formId, formClassNames } = Astro.props; } }; - if (messageField) { - messageField.addEventListener("input", updateCount); + if (messageInput) { + messageInput.addEventListener("input", updateCount); } - if (form) { + const emailInput = form?.querySelector("#form-email"); + const nameInput = + form?.querySelector("#form-full-name"); + const consentInput = + form?.querySelector("#form-consent"); + + if (form && nameInput && emailInput && messageInput && consentInput) { + form.addEventListener("click", (e) => { + if (!(e.currentTarget instanceof HTMLFormElement)) return; + const isInputTarget = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement; + if (isInputTarget && e.target.type !== "checkbox") { + const errorSpan = e.currentTarget.querySelector( + `#error-text-value-${e.target.id}`, + ); + const placeholderSpan = e.currentTarget.querySelector( + `#label-value-${e.target.id}`, + ); + placeholderSpan?.classList.remove("hidden"); + errorSpan?.classList.remove("!inline", "text-red-500"); + } + if (isInputTarget && e.target.type === "checkbox") { + consentInput.classList.replace("border-red-600", "border-gray-300"); + consentInput.classList.remove("border-2"); + } + }); + form.addEventListener("submit", async (e) => { e.preventDefault(); @@ -202,14 +243,56 @@ const { onDark, classNames, formId, formClassNames } = Astro.props; const message = formData.get("form-message"); const consent = formData.get("form-consent"); - if (!name || !email || !message || !consent) { + const isNameInputValid = + nameInput.validity.valid && !nameInput.validity.valueMissing; + const isEmailInputValid = + emailInput.validity.valid && !emailInput.validity.valueMissing; + const isMessageInputValid = + messageInput.validity.valid && !messageInput.validity.valueMissing; + const isConsentInputValid = consentInput?.validity.valid; + + if (!isNameInputValid) { + handleValidation( + nameInput, + notification.frontmatter.fullnameLength, + form, + ); + } + if (!isEmailInputValid) { + handleValidation( + emailInput, + notification.frontmatter.emailInvalid, + form, + ); + } + if (!isMessageInputValid) { + handleValidation( + messageInput, + notification.frontmatter.projectDescriptionRequired, + form, + ); + } + + if ( + !nameInput.validity.valid || + !emailInput.validity.valid || + !messageInput.validity.valid || + !isConsentInputValid + ) { + if (!consent) { + consentInput?.classList.replace( + "border-gray-300", + "border-red-600", + ); + consentInput?.classList.add("border-2"); + } return; } showLoader(); const apiUrl = new URL( - `${import.meta.env.PUBLIC_API_URL}/api/contact` + `${import.meta.env.PUBLIC_API_URL}/api/contact`, ); apiUrl.searchParams.set("source", currentUrl.pathname); diff --git a/apps/website/src/components/BookACallForm.astro b/apps/website/src/components/BookACallForm.astro index 905604ce..22f18f5b 100644 --- a/apps/website/src/components/BookACallForm.astro +++ b/apps/website/src/components/BookACallForm.astro @@ -34,7 +34,7 @@ const { class="object-cover xl:p-6 md:p-0 p-8" src={meetingImage} alt="CroCoder team member in a video call consultation" - widths={[320, 480, 640, 800, 964]} + widths={[320, 480, 640, 800, 940]} sizes="(max-width: 768px) calc(100vw - 32px), (max-width: 1280px) calc(67vw - 32px), calc(50vw - 28px)" loading="lazy" /> diff --git a/apps/website/src/components/Clients.astro b/apps/website/src/components/Clients.astro index 7eea4519..d54e7856 100644 --- a/apps/website/src/components/Clients.astro +++ b/apps/website/src/components/Clients.astro @@ -1,47 +1,10 @@ --- import Section from "./Section.astro"; +import TrustedBy from "./TrustedBy.astro"; ---
-

- Trusted By: -

-
-
- Conductor logo - Lynes logo - Submix logo - SevDesk logo - Misterspex logo -
-
- +
diff --git a/apps/website/src/components/DatePicker.astro b/apps/website/src/components/DatePicker.astro new file mode 100644 index 00000000..493cf544 --- /dev/null +++ b/apps/website/src/components/DatePicker.astro @@ -0,0 +1,95 @@ +--- +import type { HTMLAttributes } from "astro/types"; +import classnames from "classnames"; + +type Props = { + labelProps?: HTMLAttributes<"label">; + label?: string; + errorText?: string; + classNames?: string; + onDark?: boolean; + inputProps: HTMLAttributes<"input"> & { type?: "date" | string }; +}; + +const { classNames, labelProps, label = "", onDark } = Astro.props; +--- + +
+ + + + +
+
+
+ + diff --git a/apps/website/src/components/Field.astro b/apps/website/src/components/Field.astro index 63a1c913..19d94d2b 100644 --- a/apps/website/src/components/Field.astro +++ b/apps/website/src/components/Field.astro @@ -22,7 +22,13 @@ type TextAreaProps = { type Props = BaseProps & (InputProps | TextAreaProps); -const { classNames, labelProps, label, errorText, onDark } = Astro.props; +const props = Astro.props as Props; + + +const { classNames, labelProps, label, errorText, onDark } = props; +const { isTextArea } = props; + +const fieldId = isTextArea ? props.textAreaProps.id : props.inputProps.id ---
span:nth-child(2)]:hidden peer-[&:not(:placeholder-shown):not(:focus):invalid]/input:[&>span:first-child]:hidden peer-[&:not(:placeholder-shown):not(:focus):invalid]/input:[&>span:nth-child(2)]:inline - peer-[&:not(:placeholder-shown):not(:focus):invalid]/input:text-red-500`, + peer-[&:not(:placeholder-shown):not(:focus):invalid]/input:text-red-500 + `, { "text-secondary": !onDark, "text-neutral-50": onDark, }, )} > - {label} - {errorText || "Invalid field"} + {label} + {errorText || "Invalid field"}
+ + diff --git a/apps/website/src/components/Select.astro b/apps/website/src/components/Select.astro new file mode 100644 index 00000000..e31629b8 --- /dev/null +++ b/apps/website/src/components/Select.astro @@ -0,0 +1,108 @@ +--- +import type { HTMLAttributes } from "astro/types"; +import classnames from "classnames"; + +type Props = { + options: { value: string; content: string }[]; + selectProps?: HTMLAttributes<"select">; + labelProps?: HTMLAttributes<"label">; + label?: string; + classNames?: string; + onDark?: boolean; +}; + +const { options, selectProps, labelProps, label, classNames, onDark } = + Astro.props; +--- + +
+ + +
+
+
diff --git a/apps/website/src/components/TrustedBy.astro b/apps/website/src/components/TrustedBy.astro new file mode 100644 index 00000000..4458c588 --- /dev/null +++ b/apps/website/src/components/TrustedBy.astro @@ -0,0 +1,38 @@ +

+ Trusted By: +

+
+
+ Conductor logo + Lynes logo + Submix logo + SevDesk logo + Misterspex logo +
+
diff --git a/apps/website/src/components/cto/Testimonial.astro b/apps/website/src/components/cto/Testimonial.astro index 86c8e06d..7e5999a4 100644 --- a/apps/website/src/components/cto/Testimonial.astro +++ b/apps/website/src/components/cto/Testimonial.astro @@ -3,15 +3,25 @@ import { grid_classes } from "../_grid"; import Section from "../Section.astro"; import { getImage } from "astro:assets"; import raphaelBauerBubble from "../../assets/raphael-bauer-bubble.png"; +import classnames from "classnames"; const optimizedTestimonial = await getImage({ src: raphaelBauerBubble, width: 569, height: 547, }); + +type Props = { + classNames?: string; +}; + +const { classNames } = Astro.props; --- -
+
diff --git a/apps/website/src/components/icons/CircleCheck.astro b/apps/website/src/components/icons/CircleCheck.astro index 1c3a614b..3b3ab47e 100644 --- a/apps/website/src/components/icons/CircleCheck.astro +++ b/apps/website/src/components/icons/CircleCheck.astro @@ -4,7 +4,7 @@ import classnames from "classnames"; type Props = HTMLAttributes<"svg"> & { classNames?: string }; -const { width, height, classNames } = Astro.props; +const { width, height, classNames, stroke } = Astro.props; --- diff --git a/apps/website/src/components/icons/Plus.astro b/apps/website/src/components/icons/Plus.astro new file mode 100644 index 00000000..226eb639 --- /dev/null +++ b/apps/website/src/components/icons/Plus.astro @@ -0,0 +1,22 @@ +--- +import type { HTMLAttributes } from "astro/types"; + +type Props = HTMLAttributes<"svg">; + +const { width, height, fill } = Astro.props; +--- + + + + diff --git a/apps/website/src/components/staff-agmentation/GetYourPlan.astro b/apps/website/src/components/staff-agmentation/GetYourPlan.astro new file mode 100644 index 00000000..db225077 --- /dev/null +++ b/apps/website/src/components/staff-agmentation/GetYourPlan.astro @@ -0,0 +1,66 @@ +--- +import sectionImage from "../../assets/crocodile-board_v3.png"; +import { grid_classes } from "../_grid"; +import Section from "../Section.astro"; +import GetYourPlanForm from "./GetYourPlanForm.astro"; +import { Picture } from "astro:assets"; +import "../../styles/loader.css"; +--- + +
+
+
+ +
+
+

+ Get Your Free Staffing Consultation Audit +

+

+ Tell us a bit about your team and hiring challenges, and we’ll send you + a tailored recommendation on whether staff augmentation is the right fit + for you. +

+
+ +
+
diff --git a/apps/website/src/components/staff-agmentation/GetYourPlanForm.astro b/apps/website/src/components/staff-agmentation/GetYourPlanForm.astro new file mode 100644 index 00000000..e9bc0527 --- /dev/null +++ b/apps/website/src/components/staff-agmentation/GetYourPlanForm.astro @@ -0,0 +1,297 @@ +--- +import Field from "../Field.astro"; +import RightArrow from "../icons/RightArrow.astro"; +import "../../styles/loader.css"; +import * as formContent from "../../content/contact/get-your-plan-form.md"; +--- + +
+ + + +
+
+ +
+ +

+
+ + +
diff --git a/apps/website/src/components/staff-agmentation/GetYourPlanModal.astro b/apps/website/src/components/staff-agmentation/GetYourPlanModal.astro new file mode 100644 index 00000000..9531ad93 --- /dev/null +++ b/apps/website/src/components/staff-agmentation/GetYourPlanModal.astro @@ -0,0 +1,98 @@ +--- +import Field from "../Field.astro"; +import Modal from "../Modal.astro"; +import Select from "../Select.astro"; +import { quarterLabelInMonthsAhead } from "../../utils/quarter.ts"; +import * as formContent from "../../content/contact/get-your-plan-form.md"; + +const computedStartDates = (formContent.frontmatter.startDates as string[]).map( + (d) => (d === "quarter-label" ? quarterLabelInMonthsAhead(6) : d), +); +--- + + +
+

+ Let's Personalise
Your Audit +

+